From 7c9a70712ee1d05d44c5c212266baee299bc4731 Mon Sep 17 00:00:00 2001 From: what Date: Thu, 10 Sep 2026 15:39:25 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=B7=9F=E6=BA=90=E6=96=87=E4=BB=B6=E5=90=8C=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E5=A4=8D=E5=88=B6=E7=B2=98=E8=B4=B4=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=E7=9A=84=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改完之后「某个行为在哪测」就是查同名文件: lazyglobal_test.go → engine_globals_test.go static_test.go → script_static_test.go scope_pool_test.go → script_vm_test.go 它测的是 VM 池,不是作用域 esmwrap_test.go → bundle_finalize_test.go missing_global_test.go → errors_hints_test.go entry_test.go → bundle_entry_test.go 并收下 bundle_test.go 里 那三个 TestEntry_* 删掉 5 组复制粘贴。其中 TestNew_同实例调用串行 和 TestInstance_调用串行 **函数体 完全一致**,只差 mustCompile 的文件名参数 counter.js / counter.ts。Instance 的 测试全部归 instance_test.go 并统一前缀成 TestInstance_,scope_test.go 只留作用域。 vmstate_test.go + govalue_test.go → goja_facts_test.go。这两个测的都是 **goja 本身**,不是这个库:Program 能不能给多个 Runtime 共用、顶层变量跟不跟 Runtime 走、 Go 的切片进到脚本里是不是真数组。它们是「为什么这个库要这么设计」的实验记录, goja 行为变了会先红。原来那个名字看不出这层定位。 helper 集中到 helper_test.go:newEngine / mustCompile 被十几个文件依赖,原来住在 script_test.go 里,那个名字暗示「测 Script」,找不到。 caller_example_test.go 并进 caller_test.go——同一个类型没必要两个文件。 overlay_test.go 和 script_vm_test.go 保持内部测试,各写了一句为什么:前者要读 Engine.loader 和 isPrepared,后者要直接断言 vmHandle.scoped。我试过把 overlay 挪成外部测试,挪不动。 --- entry_test.go => bundle_entry_test.go | 56 ++++ esmwrap_test.go => bundle_finalize_test.go | 0 bundle_test.go | 58 ----- caller_example_test.go | 96 ------- caller_test.go | 88 +++++++ lazyglobal_test.go => engine_globals_test.go | 0 ...ing_global_test.go => errors_hints_test.go | 0 vmstate_test.go => goja_facts_test.go | 101 +++++++- govalue_test.go | 96 ------- helper_test.go | 46 ++++ instance_test.go | 239 ++++++++++-------- overlay_test.go | 4 + scope_test.go | 190 -------------- static_test.go => script_static_test.go | 0 script_test.go | 21 -- scope_pool_test.go => script_vm_test.go | 4 + 16 files changed, 427 insertions(+), 572 deletions(-) rename entry_test.go => bundle_entry_test.go (76%) rename esmwrap_test.go => bundle_finalize_test.go (100%) delete mode 100644 caller_example_test.go rename lazyglobal_test.go => engine_globals_test.go (100%) rename missing_global_test.go => errors_hints_test.go (100%) rename vmstate_test.go => goja_facts_test.go (83%) delete mode 100644 govalue_test.go create mode 100644 helper_test.go rename static_test.go => script_static_test.go (100%) rename scope_pool_test.go => script_vm_test.go (95%) diff --git a/entry_test.go b/bundle_entry_test.go similarity index 76% rename from entry_test.go rename to bundle_entry_test.go index f46ff29..0a4e157 100644 --- a/entry_test.go +++ b/bundle_entry_test.go @@ -211,3 +211,59 @@ func TestEntry_池化下实例状态会丢(t *testing.T) { t.Errorf("池化模式换了 VM,实例状态应该归零,got %#v", got) } } + +// 直接 Compile 的源码不能有 import,报错要指出走 esm 子包。 +func TestEntry_Compile不支持import(t *testing.T) { + e := newEngine(t) + _, err := e.Compile("imp.ts", `import x from "./other" +export default class H { m() { return x } }`) + if err == nil { + t.Fatal("应该报错") + } + for _, want := range []string{"Could not resolve", "jscriptx/esm"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("报错里没有 %q: %v", want, err) + } + } + t.Logf("%v", err) +} + +// 配了 WithResolveDir 之后,直接 Compile 的源码也能 import。 +func TestEntry_ResolveDir(t *testing.T) { + e := newEngine(t, jscriptx.WithBundleOptions( + jscriptx.WithResolveDir("esm/testdata/app/src/Resource"), + )) + s, err := e.Compile("inline.ts", `import ResExecuter from "./ResExecuter" +export default class H { + m() { return new ResExecuter().Store("cfg") } +}`) + if err != nil { + t.Fatalf("配了 resolveDir 应该能 import: %v", err) + } + ctx := context.Background() + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + if got, _ := obj.Call(ctx, "m"); got != "stored:cfg" { + t.Errorf("got = %#v", got) + } +} + +// 语法错误在 Compile 阶段就报,带文件位置。 +func TestEntry_语法错误(t *testing.T) { + e := newEngine(t) + _, err := e.Compile("bad.ts", `export default class { 语法错误 (((`) + if err == nil { + t.Fatal("应该报语法错误") + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || jsErr.Kind != jscriptx.KindCompile { + t.Fatalf("错误分类不对: %v", err) + } + if !strings.Contains(err.Error(), "bad.ts") { + t.Errorf("报错没带文件名: %v", err) + } + t.Logf("%v", err) +} diff --git a/esmwrap_test.go b/bundle_finalize_test.go similarity index 100% rename from esmwrap_test.go rename to bundle_finalize_test.go diff --git a/bundle_test.go b/bundle_test.go index 8429748..65655c1 100644 --- a/bundle_test.go +++ b/bundle_test.go @@ -2,9 +2,7 @@ package jscriptx_test import ( "context" - "errors" "path/filepath" - "strings" "testing" "git.fsdpf.net/go/jscriptx" @@ -100,59 +98,3 @@ export function shout(s: string): string { return upper(s) }` t.Errorf("got = %#v", got) } } - -// 直接 Compile 的源码不能有 import,报错要指出走 esm 子包。 -func TestEntry_Compile不支持import(t *testing.T) { - e := newEngine(t) - _, err := e.Compile("imp.ts", `import x from "./other" -export default class H { m() { return x } }`) - if err == nil { - t.Fatal("应该报错") - } - for _, want := range []string{"Could not resolve", "jscriptx/esm"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("报错里没有 %q: %v", want, err) - } - } - t.Logf("%v", err) -} - -// 配了 WithResolveDir 之后,直接 Compile 的源码也能 import。 -func TestEntry_ResolveDir(t *testing.T) { - e := newEngine(t, jscriptx.WithBundleOptions( - jscriptx.WithResolveDir("esm/testdata/app/src/Resource"), - )) - s, err := e.Compile("inline.ts", `import ResExecuter from "./ResExecuter" -export default class H { - m() { return new ResExecuter().Store("cfg") } -}`) - if err != nil { - t.Fatalf("配了 resolveDir 应该能 import: %v", err) - } - ctx := context.Background() - obj, err := s.New(ctx) - if err != nil { - t.Fatal(err) - } - defer obj.Close() - if got, _ := obj.Call(ctx, "m"); got != "stored:cfg" { - t.Errorf("got = %#v", got) - } -} - -// 语法错误在 Compile 阶段就报,带文件位置。 -func TestEntry_语法错误(t *testing.T) { - e := newEngine(t) - _, err := e.Compile("bad.ts", `export default class { 语法错误 (((`) - if err == nil { - t.Fatal("应该报语法错误") - } - var jsErr *jscriptx.Error - if !errors.As(err, &jsErr) || jsErr.Kind != jscriptx.KindCompile { - t.Fatalf("错误分类不对: %v", err) - } - if !strings.Contains(err.Error(), "bad.ts") { - t.Errorf("报错没带文件名: %v", err) - } - t.Logf("%v", err) -} diff --git a/caller_example_test.go b/caller_example_test.go deleted file mode 100644 index 36937c4..0000000 --- a/caller_example_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package jscriptx_test - -import ( - "context" - "errors" - "fmt" - "log" - - "git.fsdpf.net/go/jscriptx" -) - -// Caller 用来实现自定义的调用约定——本库不预设脚本回调该长什么样, -// 这个例子实现的是「回调 + next」这种中间件式的约定:脚本函数写成哪种形状, -// 运行时探测出来自动适配,脚本作者不用额外声明签名。 -// -// 它同时也说明了为什么需要 Caller 而不是直接用 Call:next 是个 Go 闭包, -// 得先看脚本声明了几个形参,才知道要不要把它传进去——这些都必须在同一次 -// VM 借出期间完成。 -func ExampleCaller() { - e, err := jscriptx.New(jscriptx.WithLogger(nil)) - if err != nil { - log.Fatal(err) - } - defer e.Close() - - // 三种回调写法,脚本作者挑顺手的用 - s, err := e.Compile("ctrl.ts", ` - export function twoArgs(v, next) { return next(v) + "|脚本自己调的 next" } - export function decorator(v) { return v + "|装饰过" } - export function sideEffect(v) { /* 只做事,不返回 */ } - `) - if err != nil { - log.Fatal(err) - } - - next := func(v string) string { return "[" + v + "]" } - - // 回调签名的约定是**调用方**定的(这里定成"1 个或 2 个形参"), - // 所以哨兵错误也归调用方声明,本库不提供。 - errBadSignature := errors.New("回调签名不受支持") - - // dispatch 按脚本函数的形状分派 - dispatch := func(fn, value string) (string, error) { - var out string - err := s.WithCall(context.Background(), fn, func(c jscriptx.Caller) error { - switch n := c.Arity(); n { - case 2: - // handle(v, next):调不调 next 由脚本决定,但得把结果返回出来 - res, err := c.Call(value, next) - if err != nil { - return err - } - if res.IsEmpty() { - return fmt.Errorf("%w: 两个形参的回调必须返回结果", - errBadSignature) - } - return res.Into(&out) - - case 1: - // handle(v):有返回值当装饰器,没返回值当纯副作用 - res, err := c.Call(value) - if err != nil { - return err - } - if res.IsEmpty() { - out = next(value) - return nil - } - var decorated string - if err := res.Into(&decorated); err != nil { - return err - } - out = next(decorated) - return nil - - default: - return fmt.Errorf("%w: 形参个数必须是 1 或 2,当前是 %d", - errBadSignature, n) - } - }) - return out, err - } - - for _, fn := range []string{"twoArgs", "decorator", "sideEffect"} { - got, err := dispatch(fn, "输入") - if err != nil { - log.Fatal(err) - } - fmt.Printf("%-11s → %s\n", fn, got) - } - - // Output: - // twoArgs → [输入]|脚本自己调的 next - // decorator → [输入|装饰过] - // sideEffect → [输入] -} diff --git a/caller_test.go b/caller_test.go index c9a88af..ba7a8a6 100644 --- a/caller_test.go +++ b/caller_test.go @@ -3,6 +3,8 @@ package jscriptx_test import ( "context" "errors" + "fmt" + "log" "strings" "testing" "time" @@ -207,3 +209,89 @@ func TestWithCall_在实例上(t *testing.T) { } } } + +// Caller 用来实现自定义的调用约定——本库不预设脚本回调该长什么样, +// 这个例子实现的是「回调 + next」这种中间件式的约定:脚本函数写成哪种形状, +// 运行时探测出来自动适配,脚本作者不用额外声明签名。 +// +// 它同时也说明了为什么需要 Caller 而不是直接用 Call:next 是个 Go 闭包, +// 得先看脚本声明了几个形参,才知道要不要把它传进去——这些都必须在同一次 +// VM 借出期间完成。 +func ExampleCaller() { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + log.Fatal(err) + } + defer e.Close() + + // 三种回调写法,脚本作者挑顺手的用 + s, err := e.Compile("ctrl.ts", ` + export function twoArgs(v, next) { return next(v) + "|脚本自己调的 next" } + export function decorator(v) { return v + "|装饰过" } + export function sideEffect(v) { /* 只做事,不返回 */ } + `) + if err != nil { + log.Fatal(err) + } + + next := func(v string) string { return "[" + v + "]" } + + // 回调签名的约定是**调用方**定的(这里定成"1 个或 2 个形参"), + // 所以哨兵错误也归调用方声明,本库不提供。 + errBadSignature := errors.New("回调签名不受支持") + + // dispatch 按脚本函数的形状分派 + dispatch := func(fn, value string) (string, error) { + var out string + err := s.WithCall(context.Background(), fn, func(c jscriptx.Caller) error { + switch n := c.Arity(); n { + case 2: + // handle(v, next):调不调 next 由脚本决定,但得把结果返回出来 + res, err := c.Call(value, next) + if err != nil { + return err + } + if res.IsEmpty() { + return fmt.Errorf("%w: 两个形参的回调必须返回结果", + errBadSignature) + } + return res.Into(&out) + + case 1: + // handle(v):有返回值当装饰器,没返回值当纯副作用 + res, err := c.Call(value) + if err != nil { + return err + } + if res.IsEmpty() { + out = next(value) + return nil + } + var decorated string + if err := res.Into(&decorated); err != nil { + return err + } + out = next(decorated) + return nil + + default: + return fmt.Errorf("%w: 形参个数必须是 1 或 2,当前是 %d", + errBadSignature, n) + } + }) + return out, err + } + + for _, fn := range []string{"twoArgs", "decorator", "sideEffect"} { + got, err := dispatch(fn, "输入") + if err != nil { + log.Fatal(err) + } + fmt.Printf("%-11s → %s\n", fn, got) + } + + // Output: + // twoArgs → [输入]|脚本自己调的 next + // decorator → [输入|装饰过] + // sideEffect → [输入] +} diff --git a/lazyglobal_test.go b/engine_globals_test.go similarity index 100% rename from lazyglobal_test.go rename to engine_globals_test.go diff --git a/missing_global_test.go b/errors_hints_test.go similarity index 100% rename from missing_global_test.go rename to errors_hints_test.go diff --git a/vmstate_test.go b/goja_facts_test.go similarity index 83% rename from vmstate_test.go rename to goja_facts_test.go index 3ae9257..1af62b7 100644 --- a/vmstate_test.go +++ b/goja_facts_test.go @@ -1,19 +1,24 @@ package jscriptx_test import ( + "context" "fmt" "slices" "sync" "testing" "time" + "git.fsdpf.net/go/jscriptx" "github.com/dop251/goja" ) -// 这一组直接用 goja,不经过本库的任何封装,钉住整个设计赖以成立的两条引擎事实: +// 这一组**不测本库,测 goja**——它是「为什么这个库要这么设计」的实验记录。 +// +// 三件事: // // 1. Program 无状态,可以给任意多个 Runtime 共用——本库共用编译结果的前提 // 2. **模块顶层的变量跟着 Runtime 走,不是共享状态** +// 3. Go 的值进到脚本里长什么样(切片是不是真数组、nil 是 null 还是 undefined) // // 第 2 条是最容易误解的地方。它导致:一个脚本串行开发时表现正常(一直命中同一个 // Runtime),并发起来行为却随调度漂移——有几个 Runtime 就有几份独立的顶层状态, @@ -21,6 +26,12 @@ import ( // // 所以要在并发之间共享状态,只能把它放到 Go 侧(写一个扩展,见 Extension 接口): // Go 对象可以被每个 Runtime 各自包一层,并发安全由 Go 自己保证。 +// +// 第 3 条脚本作者是照着写代码的,一旦变了,业务脚本会**静默**走错分支(比如查出来的 +// 记录突然 Array.isArray 为 false,整段列表被当成空的)。 +// +// 这些行为变了,这一组会先红——那时候要改的是本库的设计,不是这里的断言。 +// 其中有一条是哨兵:goja 哪天允许对象跨 Runtime 了,它会失败,提醒我们可以简化设计。 const gojaCounter = ` var counter = 0; @@ -585,3 +596,91 @@ func TestGoja_独立Runtime里的JS计数器不共享(t *testing.T) { } } } + +// Go 的切片和 map 进到脚本里长什么样。 +// +// 这不是 jscriptx 的行为,是 goja 的——但脚本作者是照着这个写代码的,一旦变了, +// 业务脚本会**静默**走错分支(比如查出来的记录突然 Array.isArray 为 false, +// 整段列表被当成空的),所以在这里钉住。 +// +// 之所以走 jscriptx 而不是直接用 goja:注入前还经过一层 freeze,得连那层一起验。 +func TestGo值到脚本里的形状(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + // 照着资源查询的返回长的:[]map[string]any,里面混着切片、字符串和 nil + records := func() []map[string]any { + return []map[string]any{{ + "role": "user", + "content": "你好", + "attempts": []any{map[string]any{"name": "a.pdf"}, map[string]any{"url": "http://x"}}, + "tool_calls": `[{"id":"1"}]`, // 列里存的是 JSON 字符串 + "reasoning": nil, + }} + } + + s, err := e.Compile("shape.ts", ` + export default class C { + Run() { + const rows = getRecords() + const r = rows[0] + return { + // 切片是真数组:length / for-of / map 都能用 + rowsIsArray: Array.isArray(rows), + rowsLen: rows.length, + attemptsIsArray: Array.isArray(r.attempts), + attemptsLen: r.attempts.length, + mapped: r.attempts.map(a => a.name || a.url).join(","), + + // map[string]any 的值按 Go 的类型各自转换,不会统一成字符串 + contentType: typeof r.content, + toolCallsType: typeof r.tool_calls, + + // Go 的 nil 是 null,不是 undefined——判空要用 == null 或 === null + reasoningIsNull: r.reasoning === null, + reasoningIsUndef: r.reasoning === undefined, + + // 没有的列是 undefined,不会报错 + missingIsUndef: r.nope === undefined, + } + } + }`) + if err != nil { + t.Fatal(err) + } + + ctx := jscriptx.WithScope(context.Background(), + jscriptx.ScopeGlobals(map[string]any{"getRecords": records})) + + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + var got map[string]any + if err := obj.CallInto(ctx, "Run", &got); err != nil { + t.Fatal(err) + } + + want := map[string]any{ + "rowsIsArray": true, + "rowsLen": int64(1), + "attemptsIsArray": true, + "attemptsLen": int64(2), + "mapped": "a.pdf,http://x", + "contentType": "string", + "toolCallsType": "string", + "reasoningIsNull": true, + "reasoningIsUndef": false, + "missingIsUndef": true, + } + for k, w := range want { + if got[k] != w { + t.Errorf("%s = %#v,该是 %#v", k, got[k], w) + } + } +} diff --git a/govalue_test.go b/govalue_test.go deleted file mode 100644 index c137bf4..0000000 --- a/govalue_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package jscriptx_test - -import ( - "context" - "testing" - - "git.fsdpf.net/go/jscriptx" -) - -// Go 的切片和 map 进到脚本里长什么样。 -// -// 这不是 jscriptx 的行为,是 goja 的——但脚本作者是照着这个写代码的,一旦变了, -// 业务脚本会**静默**走错分支(比如查出来的记录突然 Array.isArray 为 false, -// 整段列表被当成空的),所以在这里钉住。 -// -// 之所以走 jscriptx 而不是直接用 goja:注入前还经过一层 freeze,得连那层一起验。 -func TestGo值到脚本里的形状(t *testing.T) { - e, err := jscriptx.New(jscriptx.WithLogger(nil)) - if err != nil { - t.Fatal(err) - } - defer e.Close() - - // 照着资源查询的返回长的:[]map[string]any,里面混着切片、字符串和 nil - records := func() []map[string]any { - return []map[string]any{{ - "role": "user", - "content": "你好", - "attempts": []any{map[string]any{"name": "a.pdf"}, map[string]any{"url": "http://x"}}, - "tool_calls": `[{"id":"1"}]`, // 列里存的是 JSON 字符串 - "reasoning": nil, - }} - } - - s, err := e.Compile("shape.ts", ` - export default class C { - Run() { - const rows = getRecords() - const r = rows[0] - return { - // 切片是真数组:length / for-of / map 都能用 - rowsIsArray: Array.isArray(rows), - rowsLen: rows.length, - attemptsIsArray: Array.isArray(r.attempts), - attemptsLen: r.attempts.length, - mapped: r.attempts.map(a => a.name || a.url).join(","), - - // map[string]any 的值按 Go 的类型各自转换,不会统一成字符串 - contentType: typeof r.content, - toolCallsType: typeof r.tool_calls, - - // Go 的 nil 是 null,不是 undefined——判空要用 == null 或 === null - reasoningIsNull: r.reasoning === null, - reasoningIsUndef: r.reasoning === undefined, - - // 没有的列是 undefined,不会报错 - missingIsUndef: r.nope === undefined, - } - } - }`) - if err != nil { - t.Fatal(err) - } - - ctx := jscriptx.WithScope(context.Background(), - jscriptx.ScopeGlobals(map[string]any{"getRecords": records})) - - obj, err := s.New(ctx) - if err != nil { - t.Fatal(err) - } - defer obj.Close() - - var got map[string]any - if err := obj.CallInto(ctx, "Run", &got); err != nil { - t.Fatal(err) - } - - want := map[string]any{ - "rowsIsArray": true, - "rowsLen": int64(1), - "attemptsIsArray": true, - "attemptsLen": int64(2), - "mapped": "a.pdf,http://x", - "contentType": "string", - "toolCallsType": "string", - "reasoningIsNull": true, - "reasoningIsUndef": false, - "missingIsUndef": true, - } - for k, w := range want { - if got[k] != w { - t.Errorf("%s = %#v,该是 %#v", k, got[k], w) - } - } -} diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 0000000..2771cf2 --- /dev/null +++ b/helper_test.go @@ -0,0 +1,46 @@ +package jscriptx_test + +import ( + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// 各个测试文件共用的脚手架。放一起是因为它们被十几个文件依赖—— +// 原来住在 script_test.go 里,那个名字暗示「测 Script」,找不到。 + +// newEngine 造一个测试用引擎,默认不打日志(避免测试输出被 console 刷屏)。 +func newEngine(t *testing.T, opts ...jscriptx.Option) *jscriptx.Engine { + t.Helper() + opts = append([]jscriptx.Option{jscriptx.WithLogger(nil)}, opts...) + e, err := jscriptx.New(opts...) + if err != nil { + t.Fatalf("New 失败: %v", err) + } + t.Cleanup(e.Close) + return e +} + +func mustCompile(t *testing.T, e *jscriptx.Engine, name, src string) *jscriptx.Script { + t.Helper() + s, err := e.Compile(name, src) + if err != nil { + t.Fatalf("编译 %s 失败: %v", name, err) + } + return s +} + +// toNumber 把脚本返回的数字统一成 float64:goja 会按数值是否为整数 +// 导出成 int64 或 float64,测试里不必纠结这个差异。 +func toNumber(t *testing.T, v any) float64 { + t.Helper() + switch n := v.(type) { + case int64: + return float64(n) + case float64: + return n + default: + t.Fatalf("不是数字: %#v", v) + return 0 + } +} diff --git a/instance_test.go b/instance_test.go index ac2b225..4ca0264 100644 --- a/instance_test.go +++ b/instance_test.go @@ -3,12 +3,14 @@ package jscriptx_test import ( "context" "errors" + "fmt" "strings" "sync" "testing" "time" "git.fsdpf.net/go/jscriptx" + "git.fsdpf.net/go/jscriptx/internal/testext" ) const objScript = ` @@ -24,79 +26,8 @@ export default class DeviceHandler { } ` -// 最基本的:new 出对象,当普通 Go 对象用。 -func TestNew_当对象用(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "device.js", objScript) - - obj, err := s.New(context.Background(), "dev-1", "温控器") - if err != nil { - t.Fatalf("New 失败: %v", err) - } - defer obj.Close() - - ctx := context.Background() - got, err := obj.Call(ctx, "info") - if err != nil { - t.Fatal(err) - } - if got != "dev-1/温控器" { - t.Errorf("info = %#v", got) - } - - for i := 1; i <= 3; i++ { - got, err := obj.Call(ctx, "onMessage", "p") - if err != nil { - t.Fatal(err) - } - if got.(int64) != int64(i) { - t.Errorf("第 %d 次 = %#v", i, got) - } - } - if obj.Calls() != 4 { - t.Errorf("Calls = %d, want 4", obj.Calls()) - } - if obj.Name() != "device.js" { - t.Errorf("Name = %q", obj.Name()) - } -} - -// 多个实例互相独立。 -func TestNew_实例互相独立(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "device.js", objScript) - ctx := context.Background() - - a, err := s.New(ctx, "dev-A", "型号A") - if err != nil { - t.Fatal(err) - } - defer a.Close() - b, err := s.New(ctx, "dev-B", "型号B") - if err != nil { - t.Fatal(err) - } - defer b.Close() - - a.Call(ctx, "onMessage", "x") - a.Call(ctx, "onMessage", "x") - got, err := b.Call(ctx, "onMessage", "x") - if err != nil { - t.Fatal(err) - } - if got.(int64) != 1 { - t.Errorf("B 被 A 污染: %#v", got) - } - if got, _ := a.Call(ctx, "info"); got != "dev-A/型号A" { - t.Errorf("A 的构造参数不对: %#v", got) - } - if got, _ := b.Call(ctx, "info"); got != "dev-B/型号B" { - t.Errorf("B 的构造参数不对: %#v", got) - } -} - // constructor 抛异常要在 New 就报出来,不用等到第一次调用。 -func TestNew_构造失败当场报错(t *testing.T) { +func TestInstance_构造失败当场报错(t *testing.T) { e := newEngine(t) s := mustCompile(t, e, "bad.js", ` export default class H { @@ -121,7 +52,7 @@ func TestNew_构造失败当场报错(t *testing.T) { t.Logf("%v", err) } -func TestNew_Close后拒绝调用(t *testing.T) { +func TestInstance_Close后拒绝调用(t *testing.T) { e := newEngine(t) s := mustCompile(t, e, "device.js", objScript) ctx := context.Background() @@ -137,10 +68,69 @@ func TestNew_Close后拒绝调用(t *testing.T) { obj.Close() // 重复关闭不该 panic } -// 同一个实例被并发调用时必须串行。 -func TestNew_同实例调用串行(t *testing.T) { +func TestInstance_Has(t *testing.T) { e := newEngine(t) - s := mustCompile(t, e, "counter.js", ` + s := mustCompile(t, e, "device.js", objScript) + obj, _ := s.New(context.Background(), "d", "m") + defer obj.Close() + + if !obj.Has("onMessage") || !obj.Has("info") { + t.Error("Has 认不出实例方法") + } + if obj.Has("nope") { + t.Error("Has 不该认出不存在的方法") + } +} + +// 实例独占 VM,this.xxx 在它活着期间跨调用保持。 +func TestInstance_状态跨调用保持(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "device.ts", deviceScript) + ctx := context.Background() + + obj, err := s.New(ctx, "device-A", "温控器") + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + for i, want := range []string{"device-A#1:m1", "device-A#2:m2", "device-A#3:m3"} { + got, err := obj.Call(ctx, "onMessage", fmt.Sprintf("m%d", i+1)) + if err != nil { + t.Fatalf("第 %d 条失败: %v", i+1, err) + } + if got != want { + t.Errorf("第 %d 条 = %#v, want %q", i+1, got, want) + } + } +} + +// 不同实例互不相干,这是并发的基础。 +func TestInstance_互相隔离(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "device.ts", deviceScript) + ctx := context.Background() + + a, _ := s.New(ctx, "device-A", "型号A") + defer a.Close() + b, _ := s.New(ctx, "device-B", "型号B") + defer b.Close() + + a.Call(ctx, "onMessage", "x") + a.Call(ctx, "onMessage", "x") + got, err := b.Call(ctx, "onMessage", "x") + if err != nil { + t.Fatal(err) + } + if got != "device-B#1:x" { + t.Errorf("B 被 A 污染: %#v", got) + } +} + +// 同一个实例的调用串行,保护 this.xxx 不错乱。 +func TestInstance_调用串行(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "counter.ts", ` export default class C { constructor() { this.n = 0 } bump() { this.n++; return this.n } @@ -176,16 +166,62 @@ func TestNew_同实例调用串行(t *testing.T) { } } -// 脚本出错会重建实例,状态归零,Resets 能查到。 -func TestNew_出错后重建(t *testing.T) { - e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) - s := mustCompile(t, e, "device.js", objScript) - ctx := context.Background() +// 多个实例并发跑,各自的状态正确。 +func TestInstance_多实例并发(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "device.ts", deviceScript) - obj, _ := s.New(ctx, "dev-1", "温控器") + const devices, messages = 16, 30 + var wg sync.WaitGroup + for d := 0; d < devices; d++ { + id := fmt.Sprintf("device-%d", d) + wg.Add(1) + go func(id string) { + defer wg.Done() + obj, err := s.New(context.Background(), id, "型号") + if err != nil { + t.Errorf("%v", err) + return + } + defer obj.Close() + for m := 0; m < messages; m++ { + got, err := obj.Call(context.Background(), "onMessage", "m") + if err != nil { + t.Errorf("%s: %v", id, err) + return + } + want := fmt.Sprintf("%s#%d:m", id, m+1) + if got != want { + t.Errorf("%s 第 %d 条 = %#v, want %q", id, m+1, got, want) + return + } + } + }(id) + } + wg.Wait() +} + +// 脚本出错会丢 this 上的状态,但扩展里的东西还在。 +func TestInstance_出错后状态归零(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) + s := mustCompile(t, e, "mix.ts", ` + export default class M { + constructor() { this.n = 0 } + bump() { store.Incr("kept", 1); return ++this.n } + spin() { while (true) {} } + } + `) + + st := testext.New() + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st)) + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } defer obj.Close() - obj.Call(ctx, "onMessage", "x") - obj.Call(ctx, "onMessage", "x") + + obj.Call(ctx, "bump") + obj.Call(ctx, "bump") if _, err := obj.Call(ctx, "spin"); !errors.Is(err, jscriptx.ErrTimeout) { t.Fatalf("want ErrTimeout, got %v", err) @@ -194,44 +230,27 @@ func TestNew_出错后重建(t *testing.T) { t.Errorf("Resets = %d, want 1", obj.Resets()) } - got, err := obj.Call(ctx, "onMessage", "x") + got, err := obj.Call(ctx, "bump") if err != nil { t.Fatal(err) } if got.(int64) != 1 { - t.Errorf("重建后状态应该归零, got %#v", got) + t.Errorf("this 上的状态应该归零, got %#v", got) } - // 构造参数会重新传一遍,所以 info 仍然正确 - if got, _ := obj.Call(ctx, "info"); got != "dev-1/温控器" { - t.Errorf("重建后构造参数丢了: %#v", got) + // 扩展在 Go 侧,不受 VM 重建影响 + if n := toNumber(t, st.Get("kept")); n != 3 { + t.Errorf("扩展里的状态不该丢, got %v want 3", n) } } -func TestNew_Has(t *testing.T) { +// 脚本关闭后实例失效。 +func TestInstance_脚本关闭后失效(t *testing.T) { e := newEngine(t) - s := mustCompile(t, e, "device.js", objScript) - obj, _ := s.New(context.Background(), "d", "m") - defer obj.Close() - - if !obj.Has("onMessage") || !obj.Has("info") { - t.Error("Has 认不出实例方法") - } - if obj.Has("nope") { - t.Error("Has 不该认出不存在的方法") - } -} - -// 脚本关闭后,已经 new 出来的实例也不能再用。 -func TestNew_脚本关闭后失效(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "device.js", objScript) + s := mustCompile(t, e, "device.ts", deviceScript) obj, _ := s.New(context.Background(), "d", "m") s.Close() - if _, err := obj.Call(context.Background(), "info"); !errors.Is(err, jscriptx.ErrClosed) { + if _, err := obj.Call(context.Background(), "onMessage", "x"); !errors.Is(err, jscriptx.ErrClosed) { t.Errorf("want ErrClosed, got %v", err) } - if _, err := s.New(context.Background(), "d", "m"); !errors.Is(err, jscriptx.ErrClosed) { - t.Errorf("关闭的脚本不该能 New, got %v", err) - } } diff --git a/overlay_test.go b/overlay_test.go index b073feb..1aa897a 100644 --- a/overlay_test.go +++ b/overlay_test.go @@ -1,5 +1,9 @@ package jscriptx +// 这是**内部测试**(package jscriptx,不是 jscriptx_test):有几条要读 +// Engine.loader 这个未导出字段、调 isPrepared 这个未导出函数,验的是"多层 +// Loader 合成之后引擎实际拿到的是什么",从外面看不见。 + import ( "errors" "fmt" diff --git a/scope_test.go b/scope_test.go index b69bc21..5e51694 100644 --- a/scope_test.go +++ b/scope_test.go @@ -3,8 +3,6 @@ package jscriptx_test import ( "context" "errors" - "fmt" - "sync" "testing" "time" @@ -27,51 +25,6 @@ const deviceScript = ` } ` -// 实例独占 VM,this.xxx 在它活着期间跨调用保持。 -func TestInstance_状态跨调用保持(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "device.ts", deviceScript) - ctx := context.Background() - - obj, err := s.New(ctx, "device-A", "温控器") - if err != nil { - t.Fatal(err) - } - defer obj.Close() - - for i, want := range []string{"device-A#1:m1", "device-A#2:m2", "device-A#3:m3"} { - got, err := obj.Call(ctx, "onMessage", fmt.Sprintf("m%d", i+1)) - if err != nil { - t.Fatalf("第 %d 条失败: %v", i+1, err) - } - if got != want { - t.Errorf("第 %d 条 = %#v, want %q", i+1, got, want) - } - } -} - -// 不同实例互不相干,这是并发的基础。 -func TestInstance_互相隔离(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "device.ts", deviceScript) - ctx := context.Background() - - a, _ := s.New(ctx, "device-A", "型号A") - defer a.Close() - b, _ := s.New(ctx, "device-B", "型号B") - defer b.Close() - - a.Call(ctx, "onMessage", "x") - a.Call(ctx, "onMessage", "x") - got, err := b.Call(ctx, "onMessage", "x") - if err != nil { - t.Fatal(err) - } - if got != "device-B#1:x" { - t.Errorf("B 被 A 污染: %#v", got) - } -} - // 作用域的核心用途:同一个 ctx 下的多个脚本共享扩展。 func TestScope_多脚本共享扩展(t *testing.T) { e := newEngine(t) @@ -289,149 +242,6 @@ func TestScope_取回扩展(t *testing.T) { } } -// 同一个实例的调用串行,保护 this.xxx 不错乱。 -func TestInstance_调用串行(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "counter.ts", ` - export default class C { - constructor() { this.n = 0 } - bump() { this.n++; return this.n } - total() { return this.n } - } - `) - - obj, err := s.New(context.Background()) - if err != nil { - t.Fatal(err) - } - defer obj.Close() - - const goroutines, per = 8, 100 - var wg sync.WaitGroup - for g := 0; g < goroutines; g++ { - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; i < per; i++ { - if _, err := obj.Call(context.Background(), "bump"); err != nil { - t.Errorf("%v", err) - return - } - } - }() - } - wg.Wait() - - got, _ := obj.Call(context.Background(), "total") - if got.(int64) != goroutines*per { - t.Errorf("串行保证失效, total = %#v, want %d", got, goroutines*per) - } -} - -// 多个实例并发跑,各自的状态正确。 -func TestInstance_多实例并发(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "device.ts", deviceScript) - - const devices, messages = 16, 30 - var wg sync.WaitGroup - for d := 0; d < devices; d++ { - id := fmt.Sprintf("device-%d", d) - wg.Add(1) - go func(id string) { - defer wg.Done() - obj, err := s.New(context.Background(), id, "型号") - if err != nil { - t.Errorf("%v", err) - return - } - defer obj.Close() - for m := 0; m < messages; m++ { - got, err := obj.Call(context.Background(), "onMessage", "m") - if err != nil { - t.Errorf("%s: %v", id, err) - return - } - want := fmt.Sprintf("%s#%d:m", id, m+1) - if got != want { - t.Errorf("%s 第 %d 条 = %#v, want %q", id, m+1, got, want) - return - } - } - }(id) - } - wg.Wait() -} - -// 脚本出错会丢 this 上的状态,但扩展里的东西还在。 -func TestInstance_出错后状态归零(t *testing.T) { - e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) - s := mustCompile(t, e, "mix.ts", ` - export default class M { - constructor() { this.n = 0 } - bump() { store.Incr("kept", 1); return ++this.n } - spin() { while (true) {} } - } - `) - - st := testext.New() - ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st)) - obj, err := s.New(ctx) - if err != nil { - t.Fatal(err) - } - defer obj.Close() - - obj.Call(ctx, "bump") - obj.Call(ctx, "bump") - - if _, err := obj.Call(ctx, "spin"); !errors.Is(err, jscriptx.ErrTimeout) { - t.Fatalf("want ErrTimeout, got %v", err) - } - if obj.Resets() != 1 { - t.Errorf("Resets = %d, want 1", obj.Resets()) - } - - got, err := obj.Call(ctx, "bump") - if err != nil { - t.Fatal(err) - } - if got.(int64) != 1 { - t.Errorf("this 上的状态应该归零, got %#v", got) - } - // 扩展在 Go 侧,不受 VM 重建影响 - if n := toNumber(t, st.Get("kept")); n != 3 { - t.Errorf("扩展里的状态不该丢, got %v want 3", n) - } -} - -// 脚本关闭后实例失效。 -func TestInstance_脚本关闭后失效(t *testing.T) { - e := newEngine(t) - s := mustCompile(t, e, "device.ts", deviceScript) - obj, _ := s.New(context.Background(), "d", "m") - - s.Close() - if _, err := obj.Call(context.Background(), "onMessage", "x"); !errors.Is(err, jscriptx.ErrClosed) { - t.Errorf("want ErrClosed, got %v", err) - } -} - -// toNumber 把脚本返回的数字统一成 float64:goja 会按数值是否为整数 -// 导出成 int64 或 float64,测试里不必纠结这个差异。 -func toNumber(t *testing.T, v any) float64 { - t.Helper() - switch n := v.(type) { - case int64: - return float64(n) - case float64: - return n - default: - t.Fatalf("不是数字: %#v", v) - return 0 - } -} - // 作用域实例重建 VM 时,要回到它**创建时**那个作用域。 // // Instance 的 VM 超时后会被丢弃,下次调用重建(见 Resets)。重建走的是 i.scope, diff --git a/static_test.go b/script_static_test.go similarity index 100% rename from static_test.go rename to script_static_test.go diff --git a/script_test.go b/script_test.go index 984009b..e398d61 100644 --- a/script_test.go +++ b/script_test.go @@ -9,27 +9,6 @@ import ( "git.fsdpf.net/go/jscriptx" ) -// newEngine 造一个测试用引擎,默认不打日志(避免测试输出被 console 刷屏)。 -func newEngine(t *testing.T, opts ...jscriptx.Option) *jscriptx.Engine { - t.Helper() - opts = append([]jscriptx.Option{jscriptx.WithLogger(nil)}, opts...) - e, err := jscriptx.New(opts...) - if err != nil { - t.Fatalf("New 失败: %v", err) - } - t.Cleanup(e.Close) - return e -} - -func mustCompile(t *testing.T, e *jscriptx.Engine, name, src string) *jscriptx.Script { - t.Helper() - s, err := e.Compile(name, src) - if err != nil { - t.Fatalf("编译 %s 失败: %v", name, err) - } - return s -} - func TestCall_基本调用(t *testing.T) { e := newEngine(t) s := mustCompile(t, e, "calc", ` diff --git a/scope_pool_test.go b/script_vm_test.go similarity index 95% rename from scope_pool_test.go rename to script_vm_test.go index 6cba21d..bba9c26 100644 --- a/scope_pool_test.go +++ b/script_vm_test.go @@ -1,5 +1,9 @@ package jscriptx +// 这是**内部测试**(package jscriptx,不是 jscriptx_test):它要直接断言 +// vmHandle.scoped 这个未导出字段。行为测试盖不住那条——borrowStatic 那条路 +// 靠 finish 永远丢弃兜底,漏标 scoped 从外面看不出来,标志位本身才是真相。 + import ( "context" "strings"