diff --git a/bench_gobind_test.go b/bench_gobind_test.go new file mode 100644 index 0000000..9bb5fe0 --- /dev/null +++ b/bench_gobind_test.go @@ -0,0 +1,120 @@ +package jscriptx_test + +import ( + "context" + "fmt" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// 脚本碰 Go 对象要付多少钱。 +// +// 每个用例在 JS 里循环 1000 次,所以报出来的 ns/op 和 allocs/op 是**1000 次访问** +// 的总量,除以 1000 才是单次。用同一个循环次数是为了让几种写法可以直接相比。 +// +// 这组数字是 internal/jscript/README.md 里"绑定的 Go 方法要多少钱"那节的来源, +// 改了绑定实现(比如 engine.go 的 freeze / lazyGlobal)之后要重新跑。 + +// benchRow 冒充一行数据库记录:有导出字段,也有方法。 +type benchRow struct { + Name string + Content string +} + +func (r *benchRow) Get(key string) string { + if key == "name" { + return r.Name + } + return r.Content +} + +func benchScript(b *testing.B, body string, globals map[string]any) { + b.Helper() + + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + b.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("bench.ts", fmt.Sprintf(` + export default class C { + Run() { + let sink = 0 + for (let i = 0; i < 1000; i++) { %s } + return sink + } + }`, body)) + if err != nil { + b.Fatal(err) + } + + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeGlobals(globals)) + obj, err := s.New(ctx) + if err != nil { + b.Fatal(err) + } + defer obj.Close() + + // 先跑一次把 VM 预热,别把首次编译算进去 + if _, err := obj.Call(ctx, "Run"); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := obj.Call(ctx, "Run"); err != nil { + b.Fatal(err) + } + } +} + +// 基准线:纯 JS 对象的属性访问。 +func Benchmark绑定_纯JS属性(b *testing.B) { + benchScript(b, `sink += row.name.length`, map[string]any{ + "row": map[string]any{"name": "第一行", "content": "内容"}, + }) +} + +// Go 结构体的导出字段。每次访问都要经过 reflect 取值并包成 goja 值。 +func Benchmark绑定_Go结构体字段(b *testing.B) { + benchScript(b, `sink += row.Name.length`, map[string]any{ + "row": &benchRow{Name: "第一行", Content: "内容"}, + }) +} + +// Go 方法调用。除了取值,每次还要现造一个函数包装。 +func Benchmark绑定_Go方法调用(b *testing.B) { + benchScript(b, `sink += row.Get("name").length`, map[string]any{ + "row": &benchRow{Name: "第一行", Content: "内容"}, + }) +} + +// 同样是调 Go 方法,但循环外先取出来存进局部变量。 +// +// 省掉的是**每次重新造函数包装**那部分——这是写业务脚本时最容易拿到的优化。 +func Benchmark绑定_Go方法提前取出(b *testing.B) { + benchScript(b, `if (i === 0) { var get = row.Get.bind(row) } + sink += get("name").length`, map[string]any{ + "row": &benchRow{Name: "第一行", Content: "内容"}, + }) +} + +// 绑定的是普通 Go 函数(扩展的 Bindings 就是这种),不是对象上的方法。 +func Benchmark绑定_Go函数(b *testing.B) { + benchScript(b, `sink += get("name").length`, map[string]any{ + "get": func(key string) string { + if key == "name" { + return "第一行" + } + return "内容" + }, + }) +} + +// 对照:什么都不碰,只跑循环本身。上面几个都要减掉这一项才是净开销。 +func Benchmark绑定_空循环(b *testing.B) { + benchScript(b, `sink += i`, map[string]any{}) +} diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..8d97981 --- /dev/null +++ b/bench_test.go @@ -0,0 +1,265 @@ +package jscriptx_test + +import ( + "context" + "testing" + "time" + + "git.fsdpf.net/go/jscriptx" + "git.fsdpf.net/go/jscriptx/internal/testext" +) + +func benchEngine(b *testing.B, opts ...jscriptx.Option) *jscriptx.Engine { + b.Helper() + opts = append([]jscriptx.Option{jscriptx.WithLogger(nil)}, opts...) + e, err := jscriptx.New(opts...) + if err != nil { + b.Fatal(err) + } + b.Cleanup(e.Close) + return e +} + +// 纯 Go 基准线:跟下面脚本版做同样的事。 +func BenchmarkNative(b *testing.B) { + state := testext.New() + onMessage := func(s *testext.Store, payload string) { + s.Set("last", payload) + s.Incr("count", 1) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + onMessage(state, "payload") + } +} + +// 单次脚本调用的开销(VM 池命中,对应生产上的常态)。 +func BenchmarkCall(b *testing.B) { + e := benchEngine(b) + s, err := e.Compile("bench", ` + export function onMessage(state, payload) { + state.Set("last", payload) + state.Incr("count", 1) + } + `) + if err != nil { + b.Fatal(err) + } + state := testext.New() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := s.Call(ctx, "onMessage", state, "payload"); err != nil { + b.Fatal(err) + } + } +} + +// 关掉超时哨兵的开销对比:哨兵每次调用要起一个 goroutine,这里量化它值多少。 +func BenchmarkCall_无超时哨兵(b *testing.B) { + e := benchEngine(b, jscriptx.WithTimeout(0)) + s, err := e.Compile("bench", ` + export function onMessage(state, payload) { + state.Set("last", payload) + state.Incr("count", 1) + } + `) + if err != nil { + b.Fatal(err) + } + state := testext.New() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := s.Call(ctx, "onMessage", state, "payload"); err != nil { + b.Fatal(err) + } + } +} + +// 并发调用,验证 VM 池在多核下的表现。 +func BenchmarkCall_并发(b *testing.B) { + e := benchEngine(b) + s, err := e.Compile("bench", ` + export function onMessage(state, payload) { + state.Set("last", payload) + state.Incr("count", 1) + } + `) + if err != nil { + b.Fatal(err) + } + ctx := context.Background() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + state := testext.New() + for pb.Next() { + if _, err := s.Call(ctx, "onMessage", state, "payload"); err != nil { + b.Fatal(err) + } + } + }) +} + +// 池化的价值:每次都新建 VM 要多花多少。用容量 1 的池加并发制造"取不到就新建"的场景太绕, +// 这里直接对比"池命中"和"强制重建"两种路径。 +func BenchmarkVM新建(b *testing.B) { + e := benchEngine(b, jscriptx.WithMaxVMs(1), jscriptx.WithTimeout(time.Minute)) + src := ` + export function onMessage(state, payload) { + state.Set("last", payload) + state.Incr("count", 1) + } + ` + state := testext.New() + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s, err := e.Compile("bench", src) // 每次重新编译 + 全新 VM 池 + if err != nil { + b.Fatal(err) + } + if _, err := s.Call(ctx, "onMessage", state, "payload"); err != nil { + b.Fatal(err) + } + } +} + +// 调用方传了可取消的 context 时,哨兵要起 goroutine 盯着,比只有超时的快路径贵一些。 +// HTTP 请求级的回调走的是这条路,这点开销相对请求本身可以忽略。 +func BenchmarkCall_带可取消context(b *testing.B) { + e := benchEngine(b) + s, err := e.Compile("bench", ` + export function onMessage(state, payload) { + state.Set("last", payload) + state.Incr("count", 1) + } + `) + if err != nil { + b.Fatal(err) + } + state := testext.New() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := s.Call(ctx, "onMessage", state, "payload"); err != nil { + b.Fatal(err) + } + } +} + +// new 一个实例要多少钱(决定了能开多少个对象) +func BenchmarkInstanceNew(b *testing.B) { + e := benchEngine(b) + s, err := e.Compile("bench", ` + export default class H { + constructor(id) { this.id = id; this.count = 0 } + onMessage(payload) { this.count++ } + } + `) + if err != nil { + b.Fatal(err) + } + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + obj, err := s.New(ctx, "dev") + if err != nil { + b.Fatal(err) + } + obj.Close() + } +} + +// 作用域里的 store vs 参数传 State:同样的业务,两种写法的开销对比 +func BenchmarkStoreExtension(b *testing.B) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + b.Fatal(err) + } + defer e.Close() + s, err := e.Compile("bench.ts", ` + export default class H { + onMessage(payload) { + store.Set("last", payload) + store.Incr("count", 1) + } + } + `) + if err != nil { + b.Fatal(err) + } + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(testext.New())) + obj, err := s.New(ctx) + if err != nil { + b.Fatal(err) + } + defer obj.Close() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := obj.Call(ctx, "onMessage", "payload"); err != nil { + b.Fatal(err) + } + } +} + +// 跟 BenchmarkInstanceCall 用完全一样的脚本和参数,唯一差别是走 VM 池而不是独占实例。 +// 用来隔离出"池借还 vs 实例加锁"的纯架构开销(这个脚本在池化下状态会丢,只作性能对照)。 +func BenchmarkCall_同脚本对照(b *testing.B) { + e := benchEngine(b) + s, err := e.Compile("bench", ` + export default class H { + constructor() { this.count = 0 } + onMessage(payload) { this.count++; return this.count } + } + `) + if err != nil { + b.Fatal(err) + } + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := s.Call(ctx, "onMessage", "payload"); err != nil { + b.Fatal(err) + } + } +} + +// 独占实例的单次调用开销:状态在 JS 实例里,不用每次把 Go 对象反射包装过去。 +func BenchmarkInstanceCall(b *testing.B) { + e := benchEngine(b) + s, err := e.Compile("bench", ` + export default class H { + constructor() { this.count = 0 } + onMessage(payload) { this.count++; return this.count } + } + `) + if err != nil { + b.Fatal(err) + } + ctx := context.Background() + obj, err := s.New(ctx) + if err != nil { + b.Fatal(err) + } + defer obj.Close() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := obj.Call(ctx, "onMessage", "payload"); err != nil { + b.Fatal(err) + } + } +} diff --git a/bundle_test.go b/bundle_test.go new file mode 100644 index 0000000..8429748 --- /dev/null +++ b/bundle_test.go @@ -0,0 +1,158 @@ +package jscriptx_test + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx" + "github.com/evanw/esbuild/pkg/api" +) + +// WithBundleDefine:编译期常量替换。 +func TestBundleOption_Define(t *testing.T) { + e := newEngine(t, jscriptx.WithBundleOptions( + jscriptx.WithBundleDefine(map[string]string{"__MODE__": `"生产"`}), + )) + s := mustCompile(t, e, "def.ts", ` + declare const __MODE__: string + export function mode(): string { return __MODE__ } + `) + + got, err := s.Call(context.Background(), "mode") + if err != nil { + t.Fatal(err) + } + if got != "生产" { + t.Errorf("常量没被替换, got %#v", got) + } +} + +// WithBundleTarget:把语法降级到指定的 ECMAScript 版本。 +// goja 对新语法的覆盖不是 100%,调低 target 是遇到问题时的出路。 +func TestBundleOption_Target(t *testing.T) { + // 用可选链和空值合并这类较新的语法,降级到 ES2015 后应该被改写掉 + src := `export function pick(o) { return o?.a?.b ?? "兜底" }` + + for _, target := range []struct { + name string + t api.Target + }{{"ES2015", api.ES2015}, {"ES2020", api.ES2020}} { + t.Run(target.name, func(t *testing.T) { + e := newEngine(t, jscriptx.WithBundleOptions(jscriptx.WithBundleTarget(target.t))) + s := mustCompile(t, e, "t.ts", src) + ctx := context.Background() + + got, err := s.Call(ctx, "pick", map[string]any{"a": map[string]any{"b": "命中"}}) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if got != "命中" { + t.Errorf("got = %#v", got) + } + if got, err := s.Call(ctx, "pick", nil); err != nil || got != "兜底" { + t.Errorf("空值合并没生效: got=%#v err=%v", got, err) + } + }) + } +} + +// WithNodePaths / WithAlias:给直接 Compile 的源码指定模块来源。 +func TestBundleOption_NodePathsAndAlias(t *testing.T) { + libs, err := filepath.Abs("esm/testdata/app/node_modules") + if err != nil { + t.Fatal(err) + } + src := `import { upper } from "tinylib" +export function shout(s: string): string { return upper(s) }` + + // 不指定时找不到:这段源码没有文件系统上下文 + e0 := newEngine(t) + if _, err := e0.Compile("x.ts", src); err == nil { + t.Error("没有解析基准时应该报错") + } + + // NodePaths 指向 node_modules 所在目录 + e1 := newEngine(t, jscriptx.WithBundleOptions(jscriptx.WithNodePaths(libs))) + s1, err := e1.Compile("x.ts", src) + if err != nil { + t.Fatalf("WithNodePaths 没生效: %v", err) + } + if got, _ := s1.Call(context.Background(), "shout", "hi"); got != "HI" { + t.Errorf("got = %#v", got) + } + + // Alias 把模块名钉到具体文件 + impl, err := filepath.Abs("esm/testdata/app/node_modules/tinylib/index.js") + if err != nil { + t.Fatal(err) + } + e2 := newEngine(t, jscriptx.WithBundleOptions( + jscriptx.WithAlias(map[string]string{"tinylib": impl}), + )) + s2, err := e2.Compile("x.ts", src) + if err != nil { + t.Fatalf("WithAlias 没生效: %v", err) + } + if got, _ := s2.Call(context.Background(), "shout", "hi"); got != "HI" { + 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 new file mode 100644 index 0000000..9f26377 --- /dev/null +++ b/caller_example_test.go @@ -0,0 +1,91 @@ +package jscriptx_test + +import ( + "context" + "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 + "]" } + + // 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: 两个形参的回调必须返回结果", + jscriptx.ErrUnsupportedSignature) + } + 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", + jscriptx.ErrUnsupportedSignature, 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 new file mode 100644 index 0000000..c9a88af --- /dev/null +++ b/caller_test.go @@ -0,0 +1,209 @@ +package jscriptx_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.fsdpf.net/go/jscriptx" +) + +// WithCall 把 VM 借出期间的控制权交给调用方,用来实现自定义的调用约定。 +func TestWithCall_探测签名并调用(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "cb.ts", ` + export function zero() { return "无参" } + export function one(a) { return "一参:" + a } + export function two(a, b) { return "二参:" + a + "," + b } + `) + ctx := context.Background() + + for _, c := range []struct { + fn string + wantArity int + args []any + want string + }{ + {"zero", 0, nil, "无参"}, + {"one", 1, []any{"x"}, "一参:x"}, + {"two", 2, []any{"x", "y"}, "二参:x,y"}, + } { + var got string + err := s.WithCall(ctx, c.fn, func(cl jscriptx.Caller) error { + if cl.Arity() != c.wantArity { + t.Errorf("%s: Arity = %d, want %d", c.fn, cl.Arity(), c.wantArity) + } + if cl.Script() != "cb.ts" { + t.Errorf("Script = %q", cl.Script()) + } + res, err := cl.Call(c.args...) + if err != nil { + return err + } + return res.Into(&got) + }) + if err != nil { + t.Fatalf("%s: %v", c.fn, err) + } + if got != c.want { + t.Errorf("%s = %q, want %q", c.fn, got, c.want) + } + } +} + +// 把 Go 闭包当参数传给脚本,脚本能直接调它——「next」就是靠这个实现的。 +func TestWithCall_传Go闭包(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "cb.ts", ` + export function handle(v, next) { return next(v) + "|脚本加的" } + `) + + calledWith := "" + next := func(v string) string { + calledWith = v + return "Go 返回的:" + v + } + + var got string + err := s.WithCall(context.Background(), "handle", func(c jscriptx.Caller) error { + res, err := c.Call("输入", next) + if err != nil { + return err + } + return res.Into(&got) + }) + if err != nil { + t.Fatal(err) + } + if calledWith != "输入" { + t.Errorf("Go 闭包没被脚本调到,calledWith = %q", calledWith) + } + if got != "Go 返回的:输入|脚本加的" { + t.Errorf("got = %q", got) + } +} + +// 脚本没返回东西时 IsEmpty 为真——「纯副作用」的写法靠它识别。 +func TestWithCall_空返回值(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "cb.ts", ` + export function nothing() {} + export function something() { return 1 } + `) + ctx := context.Background() + + check := func(fn string, wantEmpty bool) { + t.Helper() + err := s.WithCall(ctx, fn, func(c jscriptx.Caller) error { + res, err := c.Call() + if err != nil { + return err + } + if res.IsEmpty() != wantEmpty { + t.Errorf("%s: IsEmpty = %v, want %v", fn, res.IsEmpty(), wantEmpty) + } + if wantEmpty && res.Value() != nil { + t.Errorf("%s: 空返回值的 Value 应该是 nil, got %#v", fn, res.Value()) + } + return nil + }) + if err != nil { + t.Fatalf("%s: %v", fn, err) + } + } + check("nothing", true) + check("something", false) +} + +// do 里返回的错误会被包成 *Error;wrap 哨兵错误后调用方 errors.Is 能匹配到。 +func TestWithCall_自定义错误(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "cb.ts", `export function f(a, b, c) { return 1 }`) + + sentinel := errors.New("我的约定不接受三个形参") + err := s.WithCall(context.Background(), "f", func(c jscriptx.Caller) error { + if c.Arity() != 2 { + return sentinel + } + return nil + }) + if !errors.Is(err, sentinel) { + t.Fatalf("哨兵错误丢了: %v", err) + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) { + t.Fatalf("没被包成 *Error: %T", err) + } + if jsErr.Script != "cb.ts" || jsErr.Func != "f" { + t.Errorf("上下文不对: %+v", jsErr) + } +} + +// 超时、panic 这些保护跟普通调用一样生效。 +func TestWithCall_同样受保护(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) + s := mustCompile(t, e, "cb.ts", ` + export function spin() { while (true) {} } + export function boom() { throw new Error("脚本炸了") } + `) + ctx := context.Background() + + err := s.WithCall(ctx, "spin", func(c jscriptx.Caller) error { + _, err := c.Call() + return err + }) + if !errors.Is(err, jscriptx.ErrTimeout) { + t.Errorf("want ErrTimeout, got %v", err) + } + + err = s.WithCall(ctx, "boom", func(c jscriptx.Caller) error { + _, err := c.Call() + return err + }) + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || !strings.Contains(jsErr.Msg, "脚本炸了") { + t.Errorf("脚本异常没带出来: %v", err) + } + + // 函数不存在 + err = s.WithCall(ctx, "nope", func(c jscriptx.Caller) error { return nil }) + if !errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Errorf("want ErrFuncNotFound, got %v", err) + } +} + +// 实例上也能用,跑在它独占的 VM 里。 +func TestWithCall_在实例上(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "c.ts", ` + export default class C { + constructor() { this.n = 0 } + bump() { return ++this.n } + } + `) + ctx := context.Background() + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + for i := 1; i <= 3; i++ { + var got int + err := obj.WithCall(ctx, "bump", func(c jscriptx.Caller) error { + res, err := c.Call() + if err != nil { + return err + } + return res.Into(&got) + }) + if err != nil { + t.Fatal(err) + } + if got != i { + t.Errorf("第 %d 次 = %d,实例状态没保持", i, got) + } + } +} diff --git a/engine_test.go b/engine_test.go new file mode 100644 index 0000000..ac84232 --- /dev/null +++ b/engine_test.go @@ -0,0 +1,192 @@ +package jscriptx_test + +import ( + "context" + "errors" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +func TestNew_全局名校验(t *testing.T) { + for _, name := range []string{"db.From", "2fast", "有中文", "", "a-b"} { + if _, err := jscriptx.New(jscriptx.WithGlobal(name, 1)); !errors.Is(err, jscriptx.ErrBadGlobal) { + t.Errorf("全局名 %q 应该被拒绝,got %v", name, err) + } + } + for _, name := range []string{"db", "req", "_x", "$", "a1"} { + if _, err := jscriptx.New(jscriptx.WithGlobal(name, 1)); err != nil { + t.Errorf("全局名 %q 应该被接受: %v", name, err) + } + } +} + +// 白名单之外的东西脚本一律看不见。 +func TestGlobals_白名单之外看不见(t *testing.T) { + e := newEngine(t, jscriptx.WithGlobals(map[string]any{ + "allowed": func() string { return "ok" }, + })) + s := mustCompile(t, e, "wl", ` + export function probe(name) { return typeof globalThis[name] } + `) + + for _, tc := range []struct{ name, want string }{ + {"allowed", "function"}, + {"require", "undefined"}, + {"process", "undefined"}, + {"setTimeout", "undefined"}, + {"db", "undefined"}, + } { + got, err := s.Call(context.Background(), "probe", tc.name) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Errorf("typeof %s = %v, want %v", tc.name, got, tc.want) + } + } +} + +// 注入的命名空间对象必须是只读的:否则同一个 Go map 被所有 VM 共享, +// 脚本改一下既污染别的 VM 又是数据竞争。 +func TestGlobals_命名空间只读(t *testing.T) { + ns := map[string]any{ + "helper": map[string]any{"double": func(n int) int { return n * 2 }}, + } + e := newEngine(t, jscriptx.WithGlobals(ns)) + s := mustCompile(t, e, "readonly", ` + export function tamper() { + try { helper.double = null } catch (err) { /* 严格模式才会抛,这里吞掉 */ } + try { helper = null } catch (err) {} + return typeof helper.double + } + `) + + for i := 0; i < 3; i++ { + got, err := s.Call(context.Background(), "tamper") + if err != nil { + t.Fatal(err) + } + if got != "function" { + t.Fatalf("第 %d 次: 白名单被脚本改掉了: %v", i, got) + } + } + // Go 侧的原始 map 也不能被动过 + if _, ok := ns["helper"].(map[string]any)["double"].(func(int) int); !ok { + t.Error("Go 侧的白名单 map 被脚本改掉了") + } +} + +// 多返回值约定:这是脚本作者唯一需要额外理解的规则,用测试把它钉死。 +func TestMultiReturn_转换约定(t *testing.T) { + e := newEngine(t, jscriptx.WithGlobals(map[string]any{ + "one": func() string { return "hello" }, + "okPair": func() (string, error) { return "hello", nil }, + "errPair": func() (string, error) { return "", errors.New("boom") }, + "twoVals": func() (string, int) { return "a", 1 }, + "threeVal": func() (string, int, error) { return "a", 1, nil }, + "threeErr": func() (string, int, error) { return "", 0, errors.New("boom") }, + })) + s := mustCompile(t, e, "multi", ` + export function run() { + var out = {} + out.one = one() + out.okPair = okPair() // error 为 nil:拿到裸值 + out.twoVals = twoVals().join(",") // 无 error:拿到数组 + out.threeVal = threeVal().join(",") // 末位 error 为 nil:只剩前两个 + try { errPair(); out.errPair = "没抛" } catch (err) { out.errPair = "抛了: " + err.message } + try { threeErr(); out.threeErr = "没抛" } catch (err) { out.threeErr = "抛了" } + return out + } + `) + + got, err := s.Call(context.Background(), "run") + if err != nil { + t.Fatal(err) + } + out, _ := got.(map[string]any) + want := map[string]any{ + "one": "hello", + "okPair": "hello", + "twoVals": "a,1", + "threeVal": "a,1", + "threeErr": "抛了", + } + for k, v := range want { + if out[k] != v { + t.Errorf("%s = %#v, want %#v", k, out[k], v) + } + } + if s, _ := out["errPair"].(string); !strings.HasPrefix(s, "抛了") { + t.Errorf("errPair = %#v,error 非 nil 时应该变成 JS 异常", out["errPair"]) + } + t.Logf("多返回值转换结果: %v", out) +} + +func TestEngine_Reload与Invalidate(t *testing.T) { + loader := newTestLoader() + loader.put("x", `export function hi() { return "v1" }`, "v1") + e := newEngine(t, jscriptx.WithLoader(loader)) + + s1, _ := e.Script("x") + loader.put("x", `export function hi() { return "v2" }`, "v2") + + // 没开自动重载,普通取还是旧的 + s2, _ := e.Script("x") + if s1 != s2 { + t.Error("没开自动重载不该自己更新") + } + + s3, err := e.Reload("x") + if err != nil { + t.Fatal(err) + } + if got, _ := s3.Call(context.Background(), "hi"); got != "v2" { + t.Errorf("Reload 后 got %#v", got) + } + + // 旧 Script 被顶替后会被关掉,正在进行的调用不受影响、之后的调用报 ErrClosed + if _, err := s1.Call(context.Background(), "hi"); !errors.Is(err, jscriptx.ErrClosed) { + t.Errorf("被顶替的旧脚本应该已关闭,got %v", err) + } + + e.Invalidate("x") + if len(e.Names()) != 0 { + t.Errorf("Invalidate 之后缓存该空了: %v", e.Names()) + } +} + +func TestEngine_Close(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + s := mustCompile(t, e, "x", `export function f() { return 1 }`) + e.Close() + + if _, err := e.Compile("y", `export function g() {}`); !errors.Is(err, jscriptx.ErrClosed) { + t.Errorf("want ErrClosed, got %v", err) + } + if _, err := s.Call(context.Background(), "f"); !errors.Is(err, jscriptx.ErrClosed) { + t.Errorf("want ErrClosed, got %v", err) + } +} + +// 同名脚本重新 Compile 会顶替掉旧的。 +func TestEngine_重复Compile顶替(t *testing.T) { + e := newEngine(t) + mustCompile(t, e, "x", `export function f() { return 1 }`) + s2 := mustCompile(t, e, "x", `export function f() { return 2 }`) + + cached, err := e.Script("x") + if err != nil { + t.Fatal(err) + } + if cached != s2 { + t.Error("缓存里应该是最后一次编译的版本") + } + if got, _ := cached.Call(context.Background(), "f"); got.(int64) != 2 { + t.Errorf("got %#v", got) + } +} diff --git a/entry_test.go b/entry_test.go new file mode 100644 index 0000000..f46ff29 --- /dev/null +++ b/entry_test.go @@ -0,0 +1,213 @@ +package jscriptx_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.fsdpf.net/go/jscriptx" +) + +// 所有脚本都要经 esbuild 打包,入口只有一条路:模块导出。 +// 这里把各种导出形态过一遍。 +func TestEntry_各种导出形态(t *testing.T) { + cases := []struct { + name string + src string + fn string + args []any + want any + newObj bool // 需要先 New 出实例(导出的是 class) + }{ + { + name: "export default class", + src: `export default class DeviceHandler { + constructor(id) { this.id = id } + info() { return "class:" + this.id } + }`, + fn: "info", newObj: true, args: []any{"d1"}, want: "class:d1", + }, + { + name: "export default 匿名 class", + src: `export default class { + constructor(id) { this.id = id } + info() { return "匿名:" + this.id } + }`, + fn: "info", newObj: true, args: []any{"d2"}, want: "匿名:d2", + }, + { + name: "export default 实例", + src: `class H { info() { return "实例" } } + export default new H()`, + fn: "info", want: "实例", + }, + { + name: "export default 对象字面量", + src: `export default { + info: function () { return "对象" } + }`, + fn: "info", want: "对象", + }, + { + name: "export default 具名函数", + src: `export default function handler(x) { return x * 2 }`, + fn: jscriptx.DefaultFunc, args: []any{21}, want: int64(42), + }, + { + name: "export default 箭头函数", + src: `export default (x) => x + 1`, + fn: jscriptx.DefaultFunc, args: []any{41}, want: int64(42), + }, + { + name: "只有命名导出", + src: `export function Options() { return "opts" } + export function Version() { return "v1" }`, + fn: "Options", want: "opts", + }, + { + name: "继承来的方法也找得到", + src: `class Base { base() { return "base" } } + export default class Derived extends Base { + own() { return "own" } + }`, + fn: "base", newObj: true, want: "base", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "mod.ts", c.src) + ctx := context.Background() + + var got any + var err error + if c.newObj { + obj, e2 := s.New(ctx, c.args...) + if e2 != nil { + t.Fatalf("New 失败: %v", e2) + } + defer obj.Close() + got, err = obj.Call(ctx, c.fn) + } else { + got, err = s.Call(ctx, c.fn, c.args...) + } + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if got != c.want { + t.Errorf("got = %#v, want %#v", got, c.want) + } + }) + } +} + +// TypeScript 直接能写。 +func TestEntry_TypeScript(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "ts.ts", ` + interface Cfg { name: string } + + export default class C { + private count: number = 0 + constructor(private cfg: Cfg) {} + Bump(): number { return ++this.count } + Name(): string { return this.cfg.name } + } + `) + ctx := context.Background() + obj, err := s.New(ctx, map[string]any{"name": "配置A"}) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + if got, _ := obj.Call(ctx, "Name"); got != "配置A" { + t.Errorf("Name = %#v", got) + } + if got, _ := obj.Call(ctx, "Bump"); got.(int64) != 1 { + t.Errorf("Bump = %#v", got) + } +} + +// 不写 export 的脚本会被打包器摇空,报错要说清楚这一点。 +func TestEntry_没有导出时报错有指引(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "noexport.js", `function handle(x) { return x * 2 }`) + + _, err := s.Call(context.Background(), "handle", 1) + if !errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Fatalf("want ErrFuncNotFound, got %v", err) + } + var jsErr *jscriptx.Error + errors.As(err, &jsErr) + for _, want := range []string{"没有任何导出", "export"} { + if !strings.Contains(jsErr.Msg, want) { + t.Errorf("报错里没有 %q: %s", want, jsErr.Msg) + } + } + t.Logf("%s", jsErr.Msg) +} + +// constructor 抛异常要报清楚是构造阶段失败。 +func TestEntry_构造失败(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "bad.ts", ` + export default class H { + constructor(cfg) { + if (!cfg) { throw new Error("缺少配置") } + this.cfg = cfg + } + run() { return this.cfg } + } + `) + + _, err := s.New(context.Background()) // 故意不传构造参数 + if err == nil { + t.Fatal("构造失败应该报错") + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) { + t.Fatalf("错误类型不对: %T", err) + } + if jsErr.Func != "constructor" { + t.Errorf("应该指明是 constructor 出错: %+v", jsErr) + } + if !strings.Contains(jsErr.Msg, "缺少配置") { + t.Errorf("没带上脚本的错误信息: %q", jsErr.Msg) + } +} + +// 池化调用下,实例状态 = VM 状态,换了 VM 就归零,只能当缓存。 +func TestEntry_池化下实例状态会丢(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) + s := mustCompile(t, e, "counter.ts", ` + export default class Counter { + constructor() { this.count = 0 } + bump() { return ++this.count } + spin() { while (true) {} } + } + `) + ctx := context.Background() + + if got, _ := s.Call(ctx, "bump"); got.(int64) != 1 { + t.Fatalf("got %#v", got) + } + if got, _ := s.Call(ctx, "bump"); got.(int64) != 2 { + t.Fatalf("同一个 VM 内应该累加, got %#v", got) + } + + if _, err := s.Call(ctx, "spin"); !errors.Is(err, jscriptx.ErrTimeout) { + t.Fatalf("want ErrTimeout, got %v", err) + } + + got, err := s.Call(ctx, "bump") + if err != nil { + t.Fatal(err) + } + if got.(int64) != 1 { + t.Errorf("池化模式换了 VM,实例状态应该归零,got %#v", got) + } +} diff --git a/esmwrap_test.go b/esmwrap_test.go new file mode 100644 index 0000000..271bc6d --- /dev/null +++ b/esmwrap_test.go @@ -0,0 +1,151 @@ +package jscriptx_test + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// 产物必须干净:不带 esbuild 的 CommonJS interop helper。 +// 那套 helper 每建一个 VM 都要重跑一遍,是建实例开销的大头(实测差 5 倍)。 +func TestBundle_产物不含interop_helper(t *testing.T) { + for _, c := range []struct{ name, src string }{ + {"default 导出", `export default class H { m() { return 1 } }`}, + {"只有命名导出", `export function a() { return 1 } +export const B = 2`}, + {"default + 命名", `export default class H {} +export function extra() {}`}, + } { + t.Run(c.name, func(t *testing.T) { + code, err := jscriptx.Bundle("p.ts", c.src) + if err != nil { + t.Fatal(err) + } + for _, helper := range []string{"__defProp", "__toCommonJS", "__copyProps", "__export", "__esModule"} { + if strings.Contains(code, helper) { + t.Errorf("产物里出现了 interop helper %q,说明打包格式退回 IIFE/CJS 了", helper) + } + } + body := code[:strings.Index(code, "//# sourceMappingURL")] + if len(body) > 300 { + t.Errorf("代码部分 %d 字节,偏大,检查是不是又带上 helper 了:\n%s", len(body), body) + } + }) + } +} + +// 包装不能让行号偏移,否则报错定位不回源码。 +func TestBundle_包装不影响行号(t *testing.T) { + code, err := jscriptx.Bundle("p.ts", `export default class H { + m() { return 1 } +}`) + if err != nil { + t.Fatal(err) + } + // 开头的 (() => { 必须紧贴原来的第一行,不能自己占一行 + first := strings.SplitN(code, "\n", 2)[0] + if !strings.HasPrefix(first, "(() => {") { + t.Fatalf("第一行是 %q,应该以 (() => { 开头", first) + } + if strings.TrimSpace(first) == "(() => {" { + t.Error("包装单独占了一行,后面所有行号都会偏移一位") + } + + // 端到端确认:脚本第 3 行抛错,报出来也得是第 3 行 + e := newEngine(t) + s := mustCompile(t, e, "boom.ts", `export default class H { + ok() { return 1 } + boom() { throw new Error("炸了") } +}`) + obj, err := s.New(context.Background()) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + _, err = obj.Call(context.Background(), "boom") + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || len(jsErr.Stack) == 0 { + t.Fatalf("没拿到栈信息: %v", err) + } + if jsErr.Stack[0].Line != 3 { + t.Errorf("行号 = %d, want 3——包装让行号偏移了", jsErr.Stack[0].Line) + } +} + +// 改写要认得 esbuild 输出的各种 export 形态。 +func TestBundle_各种导出形态的改写(t *testing.T) { + e := newEngine(t) + ctx := context.Background() + + for _, c := range []struct { + name, src, fn string + want any + }{ + {"default class", `export default class H { m() { return "class" } }`, "m", "class"}, + {"default 匿名 class", `export default class { m() { return "匿名" } }`, "m", "匿名"}, + {"default 对象", `export default { m() { return "对象" } }`, "m", "对象"}, + {"default 具名函数", `export default function f() { return "具名" }`, jscriptx.DefaultFunc, "具名"}, + {"default 匿名函数", `export default function () { return "匿名函数" }`, jscriptx.DefaultFunc, "匿名函数"}, + {"default 箭头", `export default () => "箭头"`, jscriptx.DefaultFunc, "箭头"}, + {"只有命名导出", `export function a() { return "命名a" } +export function b() { return "命名b" }`, "a", "命名a"}, + {"default + 命名(用 default)", `export default class H { m() { return "混合" } } +export function extra() { return "额外" }`, "m", "混合"}, + {"import 后再导出", `import { helper } from "./dep" +export default class H { m() { return helper() } }`, "m", "来自依赖"}, + } { + t.Run(c.name, func(t *testing.T) { + var s *jscriptx.Script + if strings.Contains(c.src, `"./dep"`) { + // 需要一个可解析的依赖目录 + dir := t.TempDir() + writeFile(t, dir+"/dep.ts", `export function helper() { return "来自依赖" }`) + e2 := newEngine(t, jscriptx.WithBundleOptions(jscriptx.WithResolveDir(dir))) + s = mustCompile(t, e2, "m.ts", c.src) + } else { + s = mustCompile(t, e, "m.ts", c.src) + } + + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + got, err := obj.Call(ctx, c.fn) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if got != c.want { + t.Errorf("got = %#v, want %#v", got, c.want) + } + }) + } +} + +// 认不出 export 语句时的兜底:当作没有导出,给出明确报错,而不是静默出错。 +func TestBundle_没有导出时兜底(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "noexport.js", `function handle() { return 1 }`) + + _, err := s.Call(context.Background(), "handle") + if !errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Fatalf("want ErrFuncNotFound, got %v", err) + } + var jsErr *jscriptx.Error + errors.As(err, &jsErr) + if !strings.Contains(jsErr.Msg, "没有任何导出") { + t.Errorf("报错该说清楚是没写 export: %s", jsErr.Msg) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/govalue_test.go b/govalue_test.go new file mode 100644 index 0000000..c137bf4 --- /dev/null +++ b/govalue_test.go @@ -0,0 +1,96 @@ +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/instance_test.go b/instance_test.go new file mode 100644 index 0000000..ac2b225 --- /dev/null +++ b/instance_test.go @@ -0,0 +1,237 @@ +package jscriptx_test + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "git.fsdpf.net/go/jscriptx" +) + +const objScript = ` +export default class DeviceHandler { + constructor(deviceId, model) { + this.deviceId = deviceId + this.model = model + this.count = 0 + } + info() { return this.deviceId + "/" + this.model } + onMessage(payload) { this.count++; return this.count } + spin() { while (true) {} } +} +` + +// 最基本的: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) { + e := newEngine(t) + s := mustCompile(t, e, "bad.js", ` + export default class H { + constructor(cfg) { + if (!cfg) { throw new Error("缺少配置") } + } + run() { return 1 } + } + `) + + _, err := s.New(context.Background()) // 故意不传构造参数 + if err == nil { + t.Fatal("New 就该报错") + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) { + t.Fatalf("错误类型不对: %T", err) + } + if jsErr.Func != "constructor" || !strings.Contains(jsErr.Msg, "缺少配置") { + t.Errorf("错误信息不对: %+v", jsErr) + } + t.Logf("%v", err) +} + +func TestNew_Close后拒绝调用(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "device.js", objScript) + ctx := context.Background() + + obj, _ := s.New(ctx, "dev-1", "温控器") + if _, err := obj.Call(ctx, "info"); err != nil { + t.Fatal(err) + } + obj.Close() + if _, err := obj.Call(ctx, "info"); !errors.Is(err, jscriptx.ErrClosed) { + t.Errorf("want ErrClosed, got %v", err) + } + obj.Close() // 重复关闭不该 panic +} + +// 同一个实例被并发调用时必须串行。 +func TestNew_同实例调用串行(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "counter.js", ` + 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) + } +} + +// 脚本出错会重建实例,状态归零,Resets 能查到。 +func TestNew_出错后重建(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) + s := mustCompile(t, e, "device.js", objScript) + ctx := context.Background() + + obj, _ := s.New(ctx, "dev-1", "温控器") + defer obj.Close() + obj.Call(ctx, "onMessage", "x") + obj.Call(ctx, "onMessage", "x") + + 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, "onMessage", "x") + if err != nil { + t.Fatal(err) + } + if got.(int64) != 1 { + t.Errorf("重建后状态应该归零, got %#v", got) + } + // 构造参数会重新传一遍,所以 info 仍然正确 + if got, _ := obj.Call(ctx, "info"); got != "dev-1/温控器" { + t.Errorf("重建后构造参数丢了: %#v", got) + } +} + +func TestNew_Has(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) + obj, _ := s.New(context.Background(), "d", "m") + + s.Close() + if _, err := obj.Call(context.Background(), "info"); !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/lazyglobal_test.go b/lazyglobal_test.go new file mode 100644 index 0000000..27c1971 --- /dev/null +++ b/lazyglobal_test.go @@ -0,0 +1,280 @@ +package jscriptx_test + +import ( + "context" + "log/slog" + "strings" + "sync/atomic" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// countExt 记录自己的 Bindings 被取用过几次——用来验证"没访问就不 freeze"。 +type countExt struct { + name string + calls *atomic.Int64 +} + +func (e countExt) Name() string { return e.name } +func (countExt) Module() (string, string) { return "", "" } +func (e countExt) Bindings() map[string]any { + return map[string]any{ + "hit": func() string { + e.calls.Add(1) + return e.name + }, + } +} + +// 全局是惰性的:装的是访问器属性,值要等第一次读才算出来。 +// +// 直接查属性描述符——惰性注入是 {get, set},急切注入是 {value, writable}。 +// 这是两种实现唯一可靠的观测点:数"方法被调了几次"不行,因为急切注入只是 +// **包装**函数、并不调用它,两边都是 0。 +func TestLazyGlobal_是惰性的访问器(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("s.ts", ` + export default function () { + const d = Object.getOwnPropertyDescriptor(globalThis, "unused") + if (typeof d.get !== "function") return "急切: 装的是数据属性" + if (d.value !== undefined) return "急切: 有 value" + // 读一次,值要能正常算出来 + return typeof unused.hit === "function" ? "惰性" : "惰性但值不对" + }`) + if err != nil { + t.Fatal(err) + } + + var usedCalls, unusedCalls atomic.Int64 + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions( + countExt{"used", &usedCalls}, + countExt{"unused", &unusedCalls}, + )) + + got, err := s.Call(ctx, "") + if err != nil { + t.Fatal(err) + } + if got != "惰性" { + t.Errorf("全局该是惰性的访问器属性,结果 %#v", got) + } +} + +// 用得上的扩展照常能用——惰性不能把功能弄丢。 +func TestLazyGlobal_用得上的照常(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("s.ts", `export default function () { return used.hit() }`) + if err != nil { + t.Fatal(err) + } + + var usedCalls, unusedCalls atomic.Int64 + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions( + countExt{"used", &usedCalls}, + countExt{"unused", &unusedCalls}, + )) + + got, err := s.Call(ctx, "") + if err != nil { + t.Fatal(err) + } + if got != "used" { + t.Errorf("拿到 %#v", got) + } + if usedCalls.Load() != 1 { + t.Errorf("used.hit 该被调一次,实际 %d 次", usedCalls.Load()) + } + if unusedCalls.Load() != 0 { + t.Error("没用到的扩展,它的方法不该被调用") + } +} + +// 读两次只 freeze 一次——getter 里的缓存要生效。 +func TestLazyGlobal_只转换一次(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + // 每次 freeze 都会重新包装 Bindings 里的函数,所以两次读到的 + // 如果是同一个对象,就说明只转换了一次 + s, err := e.Compile("s.ts", ` + export default function () { return globalThis.ext === globalThis.ext }`) + if err != nil { + t.Fatal(err) + } + + var calls atomic.Int64 + ctx := jscriptx.WithScope(context.Background(), + jscriptx.ScopeExtensions(countExt{"ext", &calls})) + + got, err := s.Call(ctx, "") + if err != nil { + t.Fatal(err) + } + if got != true { + t.Error("两次读该拿到同一个对象,说明 freeze 只跑了一次") + } +} + +// 只读性不能变:脚本赋值不生效。 +// +// 注意断言的是"值没变"而不是"抛错"——打包产物**没有** "use strict", +// 脚本跑在非严格模式下,给只读属性赋值是静默失败的。 +func TestLazyGlobal_仍然只读(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("s.ts", ` + export default function () { + globalThis.ext = 1 + return typeof ext === "object" ? "没被改掉" : "被改成了 " + typeof ext + }`) + if err != nil { + t.Fatal(err) + } + + var calls atomic.Int64 + ctx := jscriptx.WithScope(context.Background(), + jscriptx.ScopeExtensions(countExt{"ext", &calls})) + + got, err := s.Call(ctx, "") + if err != nil { + t.Fatal(err) + } + if got != "没被改掉" { + t.Errorf("扩展全局该是只读的,结果 %#v", got) + } +} + +// 删不掉、重定义不了(configurable=false)。 +func TestLazyGlobal_不可删除(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("s.ts", ` + export default function () { + delete globalThis.ext + return typeof ext === "object" ? "还在" : "被删了" + }`) + if err != nil { + t.Fatal(err) + } + + var calls atomic.Int64 + ctx := jscriptx.WithScope(context.Background(), + jscriptx.ScopeExtensions(countExt{"ext", &calls})) + + got, err := s.Call(ctx, "") + if err != nil { + t.Fatal(err) + } + if got != "还在" { + t.Errorf("configurable 该是 false,删不掉,结果 %#v", got) + } +} + +// 每个 VM 各转各的——goja.Value 跨不了 Runtime,缓存绝不能共享。 +func TestLazyGlobal_每个VM各一份(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("s.ts", `export default function () { return ext.hit() }`) + if err != nil { + t.Fatal(err) + } + + var calls atomic.Int64 + // 每次都是新作用域 → 新 VM(带作用域的调用不走池) + for range 5 { + ctx := jscriptx.WithScope(context.Background(), + jscriptx.ScopeExtensions(countExt{"ext", &calls})) + if _, err := s.Call(ctx, ""); err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 5 { + t.Errorf("5 个 VM 各调一次,该是 5 次,实际 %d 次", got) + } +} + +// 白名单全局(WithGlobals)同样是惰性的,行为跟以前一致。 +func TestLazyGlobal_白名单也惰性(t *testing.T) { + e, err := jscriptx.New( + jscriptx.WithLogger(nil), + jscriptx.WithGlobals(map[string]any{ + "cfg": map[string]any{"name": "线上", "port": 8080}, + }), + ) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("s.ts", `export default function () { return cfg.name + ":" + cfg.port }`) + if err != nil { + t.Fatal(err) + } + + got, err := s.Call(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if got != "线上:8080" { + t.Errorf("拿到 %#v", got) + } + + // 嵌套的对象也该是只读的 + s2, _ := e.Compile("s2.ts", ` + export default function () { cfg.name = "改了"; return cfg.name }`) + if got, _ := s2.Call(context.Background(), ""); got != "线上" { + t.Errorf("嵌套字段该只读、改不动,结果 %#v", got) + } +} + +// newTestLogger 造一个把输出写进 buf 的 slog,用来验 console。 +func newTestLogger(buf *strings.Builder) *slog.Logger { + return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// console 也走惰性注入,用法不变。 +func TestLazyGlobal_console照常(t *testing.T) { + var buf strings.Builder + e, err := jscriptx.New(jscriptx.WithLogger(newTestLogger(&buf))) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("s.ts", `export default function () { console.log("来自脚本"); return 1 }`) + if err != nil { + t.Fatal(err) + } + if _, err := s.Call(context.Background(), ""); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "来自脚本") { + t.Errorf("console.log 该照常落到日志,拿到 %q", buf.String()) + } +} diff --git a/loader_test.go b/loader_test.go new file mode 100644 index 0000000..2f87414 --- /dev/null +++ b/loader_test.go @@ -0,0 +1,228 @@ +package jscriptx_test + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// testLoader 是测试用的 Loader 实现:本库只定义接口,实现由调用方提供。 +type testLoader struct { + mu sync.Mutex + sources map[string]string + version map[string]string + calls int +} + +func newTestLoader() *testLoader { + return &testLoader{sources: map[string]string{}, version: map[string]string{}} +} + +func (l *testLoader) put(name, src, version string) { + l.mu.Lock() + defer l.mu.Unlock() + l.sources[name] = src + l.version[name] = version +} + +func (l *testLoader) Load(name string) (string, string, error) { + l.mu.Lock() + defer l.mu.Unlock() + l.calls++ + src, ok := l.sources[name] + if !ok { + return "", "", fmt.Errorf("%w: %s", jscriptx.ErrScriptNotFound, name) + } + return src, l.version[name], nil +} + +func (l *testLoader) callCount() int { + l.mu.Lock() + defer l.mu.Unlock() + return l.calls +} + +func TestLoader_按名字加载并缓存(t *testing.T) { + loader := newTestLoader() + loader.put("greet", `export function hi() { return "v1" }`, "v1") + + e := newEngine(t, jscriptx.WithLoader(loader)) + + for i := 0; i < 3; i++ { + s, err := e.Script("greet") + if err != nil { + t.Fatal(err) + } + got, err := s.Call(context.Background(), "hi") + if err != nil { + t.Fatal(err) + } + if got != "v1" { + t.Fatalf("got %#v", got) + } + } + if loader.callCount() != 1 { + t.Errorf("没开自动重载时应该只加载一次,实际 %d 次", loader.callCount()) + } +} + +func TestLoader_脚本不存在(t *testing.T) { + e := newEngine(t, jscriptx.WithLoader(newTestLoader())) + _, err := e.Script("nope") + if !errors.Is(err, jscriptx.ErrScriptNotFound) { + t.Fatalf("want ErrScriptNotFound, got %v", err) + } +} + +func TestLoader_没配Loader也没注册(t *testing.T) { + e := newEngine(t) + if _, err := e.Script("nope"); !errors.Is(err, jscriptx.ErrScriptNotFound) { + t.Fatalf("want ErrScriptNotFound, got %v", err) + } +} + +// 热更新:版本号变了才重新编译,没变就复用编译产物和 VM 池。 +func TestLoader_自动热更新(t *testing.T) { + loader := newTestLoader() + loader.put("greet", `export function hi() { return "v1" }`, "v1") + + e := newEngine(t, jscriptx.WithLoader(loader), jscriptx.WithAutoReload(true)) + + s1, err := e.Script("greet") + if err != nil { + t.Fatal(err) + } + got, _ := s1.Call(context.Background(), "hi") + if got != "v1" { + t.Fatalf("got %#v", got) + } + + // 版本号没变:应该拿到同一个 Script + s2, err := e.Script("greet") + if err != nil { + t.Fatal(err) + } + if s1 != s2 { + t.Error("版本号没变时不该重新编译") + } + + // 版本号变了:重新编译,新调用走新逻辑 + loader.put("greet", `export function hi() { return "v2" }`, "v2") + s3, err := e.Script("greet") + if err != nil { + t.Fatal(err) + } + if s3 == s1 { + t.Fatal("版本号变了应该重新编译") + } + if got, _ := s3.Call(context.Background(), "hi"); got != "v2" { + t.Errorf("热更新后 got %#v, want v2", got) + } + if s3.Version() != "v2" { + t.Errorf("Version() = %q", s3.Version()) + } +} + +// Loader 不提供版本号时,退化成按源码哈希判断有没有变。 +func TestLoader_不提供版本号时按源码哈希(t *testing.T) { + loader := newTestLoader() + loader.put("x", `export function hi() { return 1 }`, "") + + e := newEngine(t, jscriptx.WithLoader(loader), jscriptx.WithAutoReload(true)) + s1, err := e.Script("x") + if err != nil { + t.Fatal(err) + } + s2, err := e.Script("x") + if err != nil { + t.Fatal(err) + } + if s1 != s2 { + t.Error("源码没变不该重新编译") + } + + loader.put("x", `export function hi() { return 2 }`, "") + s3, err := e.Script("x") + if err != nil { + t.Fatal(err) + } + if s3 == s1 { + t.Error("源码变了应该重新编译") + } +} + +// LoaderFunc:让普通函数直接当 Loader 用。 +func TestLoaderFunc(t *testing.T) { + scripts := map[string]string{ + "greet": `export function hi() { return "来自 LoaderFunc" }`, + } + loader := jscriptx.LoaderFunc(func(name string) (string, string, error) { + src, ok := scripts[name] + if !ok { + return "", "", errors.New("没有这个脚本: " + jscriptx.ErrScriptNotFound.Error() + " " + name) + } + return src, "v1", nil + }) + + e := newEngine(t, jscriptx.WithLoader(loader)) + s, err := e.Script("greet") + if err != nil { + t.Fatalf("LoaderFunc 没生效: %v", err) + } + got, err := s.Call(context.Background(), "hi") + if err != nil { + t.Fatal(err) + } + if got != "来自 LoaderFunc" { + t.Errorf("got = %#v", got) + } + + if _, err := e.Script("nope"); err == nil || !strings.Contains(err.Error(), "nope") { + t.Errorf("找不到脚本时该把 Loader 的错误带出来: %v", err) + } +} + +// preparedLoader 声称源码已经打过包了。 +type preparedLoader struct{ src string } + +func (l preparedLoader) Load(string) (string, string, error) { return l.src, "v1", nil } +func (l preparedLoader) Prepared() bool { return true } + +// plainLoader 不实现 Prepared,源码交给 Engine 打包。 +type plainLoader struct{ src string } + +func (l plainLoader) Load(string) (string, string, error) { return l.src, "v1", nil } + +// Prepared 决定 Engine 打不打包这个 Loader 给的源码。 +// +// 用一段原始 ESM 源码就能把两条路分辨开:Engine 打包的话 export 会被抹平, +// 不打包直接丢给 goja 的话,export 是保留字,编译当场失败。 +func TestLoader_Prepared决定是否打包(t *testing.T) { + const esmSource = `export default class H { hi() { return "ok" } }` + + // 不实现 Prepared:Engine 负责打包,ESM 源码能用 + e1 := newEngine(t, jscriptx.WithLoader(plainLoader{esmSource})) + s, err := e1.Script("x") + if err != nil { + t.Fatalf("Engine 应该打包这段源码: %v", err) + } + obj, err := s.New(context.Background()) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + if got, _ := obj.Call(context.Background(), "hi"); got != "ok" { + t.Errorf("got = %#v", got) + } + + // 实现了 Prepared:Engine 跳过打包,原始 ESM 直接交给 goja 就会语法错误 + e2 := newEngine(t, jscriptx.WithLoader(preparedLoader{esmSource})) + if _, err := e2.Script("x"); err == nil { + t.Fatal("声称已打包却给原始 ESM,应该编译失败——说明 Prepared 没被识别,源码被重复打包了") + } +} diff --git a/missing_global_test.go b/missing_global_test.go new file mode 100644 index 0000000..ea00c1b --- /dev/null +++ b/missing_global_test.go @@ -0,0 +1,117 @@ +package jscriptx_test + +import ( + "context" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// 脚本碰到 goja 没有的全局时,光一句 ReferenceError 会把人带偏—— +// 外部作者看到 "setTimeout is not defined" 会以为是打包漏了依赖,跑去装 polyfill。 +// 这组用例守着那句补充说明。 +func TestMissingGlobal_报错带上人话(t *testing.T) { + cases := []struct { + 代码 string + 要点 []string + }{ + {`setTimeout(() => {}, 10)`, []string{"没有事件循环", "Job / Cron"}}, + {`clearInterval(1)`, []string{"定时器用不了"}}, + {`structuredClone({})`, []string{"JSON.parse", "深拷贝"}}, + {`const 名 = "lodash"; require(名)`, []string{"ES 模块", "import"}}, // 动态名字,esbuild 打包期解析不了,才会漏到运行期 + {`process.env.X`, []string{"不跑在 Node"}}, + {`document.body`, []string{"没有 DOM"}}, + {`fetch("http://x")`, []string{"同步执行", "Go 侧"}}, + } + + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + for _, c := range cases { + s, err := e.Compile("m.ts", "export default class C { Run() { "+c.代码+" } }") + if err != nil { + t.Fatalf("%s 编译失败: %v", c.代码, err) + } + ctx := context.Background() + obj, err := s.New(ctx) + if err != nil { + t.Fatalf("%s 实例化失败: %v", c.代码, err) + } + _, err = obj.Call(ctx, "Run") + obj.Close() + + if err == nil { + t.Errorf("%s 该报错", c.代码) + continue + } + for _, want := range c.要点 { + if !strings.Contains(err.Error(), want) { + t.Errorf("%s 的错误里该提到 %q\n实际: %v", c.代码, want, err) + } + } + } +} + +// 只认 goja 原样的那句,别把脚本自己抛的同名字符串也当成缺全局。 +func TestMissingGlobal_不误伤(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("m.ts", ` + export default class C { + Run() { throw new Error("ReferenceError: setTimeout is not defined") } + }`) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + obj, _ := s.New(ctx) + defer obj.Close() + + _, err = obj.Call(ctx, "Run") + if err == nil { + t.Fatal("该报错") + } + // 脚本自己抛的,goja 会加上 "Error: " 前缀,前缀对不上就不该补提示 + if strings.Contains(err.Error(), "没有事件循环") { + t.Errorf("不该给脚本自己抛的异常补提示: %v", err) + } +} + +// 定义成"一调就报错"的桩会破坏特性探测,所以这些全局必须保持 undefined。 +func TestMissingGlobal_特性探测仍然可用(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("m.ts", ` + export default class C { + Run() { + // 库里常见的降级写法 + return typeof setTimeout === "undefined" ? "走降级分支" : "走定时器分支" + } + }`) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + obj, _ := s.New(ctx) + defer obj.Close() + + got, err := obj.Call(ctx, "Run") + if err != nil { + t.Fatal(err) + } + if got != "走降级分支" { + t.Errorf("定时器该是 undefined,好让库能降级,拿到 %#v", got) + } +} diff --git a/overlay_test.go b/overlay_test.go new file mode 100644 index 0000000..b073feb --- /dev/null +++ b/overlay_test.go @@ -0,0 +1,419 @@ +package jscriptx + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +// srcLoader 是个最简单的内存 Loader,用来搭叠层测试。 +type srcLoader struct { + src map[string]string + ver map[string]string + prepared bool + names []string + rebuilt int + fail error // 非 ErrScriptNotFound 的故障 +} + +func (l *srcLoader) Load(name string) (string, string, error) { + if l.fail != nil { + return "", "", l.fail + } + s, ok := l.src[name] + if !ok { + return "", "", fmt.Errorf("%w: %s", ErrScriptNotFound, name) + } + return s, l.ver[name], nil +} +func (l *srcLoader) Prepared() bool { return l.prepared } +func (l *srcLoader) Names() []string { return l.names } +func (l *srcLoader) Rebuild() error { l.rebuilt++; return nil } + +func clsOf(v string) string { + return "export default class C { V() { return " + `"` + v + `"` + " } }" +} + +func TestOverlay_后面的盖前面的(t *testing.T) { + base := &srcLoader{src: map[string]string{"A": clsOf("base-A"), "B": clsOf("base-B")}} + over := &srcLoader{src: map[string]string{"A": clsOf("over-A"), "C": clsOf("over-C")}} + + o, err := Overlay(base, over) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct{ name, want string }{ + {"A", "over-A"}, // 两层都有,取后面那层 + {"B", "base-B"}, // 只有前面那层有 + {"C", "over-C"}, // 只有后面那层有 + } { + src, _, err := o.Load(tc.name) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if !strings.Contains(src, tc.want) { + t.Errorf("%s 取到的不是 %s: %s", tc.name, tc.want, src) + } + } +} + +func TestOverlay_都没有时报脚本不存在(t *testing.T) { + o, err := Overlay(&srcLoader{}, &srcLoader{}) + if err != nil { + t.Fatal(err) + } + _, _, err = o.Load("X") + if !errors.Is(err, ErrScriptNotFound) { + t.Fatalf("要能被 ErrScriptNotFound 匹配上: %v", err) + } + if !strings.Contains(err.Error(), "X") { + t.Errorf("报错里要带脚本名: %v", err) + } +} + +// 某一层出故障(不是"脚本不存在")时要立刻报出来, +// 不能被下面那层的结果悄悄盖掉——否则线上换了实现都不知道。 +func TestOverlay_故障不被下层掩盖(t *testing.T) { + boom := errors.New("数据库连不上") + base := &srcLoader{src: map[string]string{"A": clsOf("base-A")}} + over := &srcLoader{fail: boom} + + o, _ := Overlay(base, over) + _, _, err := o.Load("A") + if !errors.Is(err, boom) { + t.Fatalf("故障被吞了: %v", err) + } +} + +// 版本号要带上层号:覆盖层撤掉后落回下层,两层版本号万一撞上, +// 不带层号引擎就看不出脚本已经换了人。 +func TestOverlay_版本号带层号(t *testing.T) { + base := &srcLoader{ + src: map[string]string{"A": clsOf("base-A")}, + ver: map[string]string{"A": "v1"}, + } + over := &srcLoader{ + src: map[string]string{"A": clsOf("over-A")}, + ver: map[string]string{"A": "v1"}, // 故意跟 base 撞 + } + + o, _ := Overlay(base, over) + _, v1, _ := o.Load("A") + + delete(over.src, "A") // 撤掉覆盖 + _, v2, _ := o.Load("A") + + if v1 == v2 { + t.Fatalf("落回下层后版本号没变(都是 %q),引擎会继续用旧的编译结果", v1) + } +} + +// 成员不给版本号时要原样透传空串,让引擎去按源码算哈希。 +func TestOverlay_空版本号原样透传(t *testing.T) { + o, _ := Overlay(&srcLoader{src: map[string]string{"A": clsOf("a")}}) + if _, v, _ := o.Load("A"); v != "" { + t.Errorf("版本号该是空的,拿到 %q", v) + } +} + +func TestOverlay_Prepared必须一致(t *testing.T) { + yes := &srcLoader{prepared: true} + no := &srcLoader{prepared: false} + + if _, err := Overlay(yes, no); err == nil { + t.Error("混着来该报错") + } + if _, err := Overlay(yes, yes); err != nil { + t.Errorf("都是打好包的该通过: %v", err) + } + o, err := Overlay(no, no) + if err != nil { + t.Fatalf("都是原始源码该通过: %v", err) + } + if o.Prepared() { + t.Error("Prepared 该跟成员一致") + } +} + +func TestOverlay_参数校验(t *testing.T) { + if _, err := Overlay(); err == nil { + t.Error("一个都不给该报错") + } + if _, err := Overlay(&srcLoader{}, nil); err == nil { + t.Error("给 nil 该报错") + } +} + +func TestOverlay_Names去重排序(t *testing.T) { + base := &srcLoader{names: []string{"B/b", "A/a"}} + over := &srcLoader{names: []string{"A/a", "C/c"}} + plain := LoaderFunc(func(string) (string, string, error) { return "", "", nil }) // 没有 Names + + o, _ := Overlay(base, over, plain) + got := strings.Join(o.Names(), ",") + if got != "A/a,B/b,C/c" { + t.Errorf("Names() = %s", got) + } +} + +func TestOverlay_Rebuild走遍每一层(t *testing.T) { + base, over := &srcLoader{}, &srcLoader{} + o, _ := Overlay(base, over, LoaderFunc(func(string) (string, string, error) { return "", "", nil })) + if err := o.Rebuild(); err != nil { + t.Fatal(err) + } + if base.rebuilt != 1 || over.rebuilt != 1 { + t.Errorf("每层都该重建一次: base=%d over=%d", base.rebuilt, over.rebuilt) + } +} + +// WithLoader 给多个时自动叠起来,跟 Overlay 的语义一致。 +func TestWithLoader_多个自动叠层(t *testing.T) { + base := &srcLoader{src: map[string]string{"A": clsOf("base-A"), "B": clsOf("base-B")}} + over := &srcLoader{src: map[string]string{"A": clsOf("over-A")}} + + e, err := New(WithLoader(base, over)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + mustCall := func(name, want string) { + t.Helper() + s, err := e.Script(name) + if err != nil { + t.Fatal(err) + } + got, err := s.Call(t.Context(), "V") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("%s = %v, 想要 %s", name, got, want) + } + } + mustCall("A", "over-A") + mustCall("B", "base-B") +} + +// 多次调用 WithLoader 继续往后叠,效果跟一次传多个一样。 +func TestWithLoader_多次调用继续叠(t *testing.T) { + base := &srcLoader{src: map[string]string{"A": clsOf("base-A")}} + over := &srcLoader{src: map[string]string{"A": clsOf("over-A")}} + + e, err := New(WithLoader(base), WithLoader(over)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Script("A") + if err != nil { + t.Fatal(err) + } + if got, _ := s.Call(t.Context(), "V"); got != "over-A" { + t.Errorf("后面那次该盖住前面的,拿到 %v", got) + } +} + +// 只给一个时不该套上 Overlay,Prepared 之类的行为要原样保留。 +func TestWithLoader_单个不套壳(t *testing.T) { + l := &srcLoader{prepared: true} + e, err := New(WithLoader(l)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + if e.loader != Loader(l) { + t.Errorf("单个 Loader 该原样用,拿到 %T", e.loader) + } +} + +func TestWithLoader_层间Prepared不一致时New报错(t *testing.T) { + _, err := New(WithLoader(&srcLoader{prepared: true}, &srcLoader{prepared: false})) + if err == nil { + t.Fatal("该报错") + } + if !strings.Contains(err.Error(), "Prepared") { + t.Errorf("报错要说清楚是 Prepared 不一致: %v", err) + } +} + +// tagged 是带版本标签的内存 Loader。 +type tagged struct { + *srcLoader + ver string +} + +func (t *tagged) Version() string { return t.ver } + +func TestVersion_点名要哪一层(t *testing.T) { + v1 := &tagged{&srcLoader{src: map[string]string{"A": clsOf("v1-A"), "OnlyV1": clsOf("x")}}, "v1"} + v2 := &tagged{&srcLoader{src: map[string]string{"A": clsOf("v2-A")}}, "v2"} + + o, err := Overlay(v1, v2) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct{ name, want string }{ + {"A", "v2-A"}, // 不点名:上层盖下层 + {"A@v2", "v2-A"}, // 点名上层 + {"A@v1", "v1-A"}, // 点名下层 + {"OnlyV1@v1", "x"}, // 点名的那层独有的 + } { + src, _, err := o.Load(tc.name) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if !strings.Contains(src, tc.want) { + t.Errorf("%s 该拿到 %s: %s", tc.name, tc.want, src) + } + } +} + +// 点名是"只认这一层":那层没有就报错,不能掉到别的层去。 +func TestVersion_点名后不回落(t *testing.T) { + v1 := &tagged{&srcLoader{src: map[string]string{"A": clsOf("v1-A")}}, "v1"} + v2 := &tagged{&srcLoader{}, "v2"} // 空的 + + o, _ := Overlay(v1, v2) + _, _, err := o.Load("A@v2") + if !errors.Is(err, ErrScriptNotFound) { + t.Fatalf("v2 层没有 A,该报不存在而不是回落到 v1: %v", err) + } +} + +func TestVersion_点名不存在的版本(t *testing.T) { + v1 := &tagged{&srcLoader{src: map[string]string{"A": clsOf("a")}}, "v1"} + o, _ := Overlay(v1) + + _, _, err := o.Load("A@v9") + if !errors.Is(err, ErrScriptNotFound) { + t.Fatalf("该报不存在: %v", err) + } + if !strings.Contains(err.Error(), "v1") { + t.Errorf("报错该提示有哪些版本: %v", err) + } +} + +// 点名不同的层,版本号里的层号要跟着变,两份实现才能各自缓存。 +func TestVersion_点名时版本号带层号(t *testing.T) { + v1 := &tagged{&srcLoader{ + src: map[string]string{"A": clsOf("v1-A")}, + ver: map[string]string{"A": "same"}, + }, "v1"} + v2 := &tagged{&srcLoader{ + src: map[string]string{"A": clsOf("v2-A")}, + ver: map[string]string{"A": "same"}, // 故意撞 + }, "v2"} + + o, _ := Overlay(v1, v2) + _, a, _ := o.Load("A@v1") + _, b, _ := o.Load("A@v2") + if a == b { + t.Fatalf("两层的版本号该区分开,都是 %q", a) + } +} + +func TestVersion_标签校验(t *testing.T) { + a := &tagged{&srcLoader{}, "v1"} + b := &tagged{&srcLoader{}, "v1"} + if _, err := Overlay(a, b); err == nil { + t.Error("标签重名该报错") + } + bad := &tagged{&srcLoader{}, "v@1"} + if _, err := Overlay(bad); err == nil { + t.Error("标签里带分隔符该报错") + } +} + +func TestVersion_Tag给任意Loader贴标签(t *testing.T) { + plain := &srcLoader{src: map[string]string{"A": clsOf("plain-A")}} + other := &srcLoader{src: map[string]string{"A": clsOf("other-A")}} + + o, err := Overlay(plain, Tag("hotfix", other)) + if err != nil { + t.Fatal(err) + } + src, _, err := o.Load("A@hotfix") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(src, "other-A") { + t.Errorf("该拿到贴了标签那层的: %s", src) + } + if got := strings.Join(o.Versions(), ","); got != "hotfix" { + t.Errorf("Versions() = %s", got) + } +} + +// 贴标签不该改变底下 Loader 的 Prepared,否则叠层的一致性校验会误判。 +func TestVersion_Tag透传Prepared(t *testing.T) { + if !isPrepared(Tag("v1", &srcLoader{prepared: true})) { + t.Error("底下是 prepared 的,贴完标签该还是") + } + if isPrepared(Tag("v1", &srcLoader{prepared: false})) { + t.Error("底下不是 prepared 的,贴完标签不该变成是") + } + // 一层贴了标签一层没贴,Prepared 一致就该能叠 + if _, err := Overlay(&srcLoader{prepared: true}, Tag("v1", &srcLoader{prepared: true})); err != nil { + t.Errorf("该能叠: %v", err) + } +} + +func TestVersion_拆名字(t *testing.T) { + for _, tc := range []struct { + in, bare, ver string + ok bool + }{ + {"Foo/Bar@v1", "Foo/Bar", "v1", true}, + {"Foo/Bar", "Foo/Bar", "", false}, + {"@v1", "@v1", "", false}, // 左边空着,不算点名 + {"Foo@", "Foo@", "", false}, // 右边空着,不算点名 + {"a@b@v1", "a@b", "v1", true}, // 用最后一个分隔符 + } { + bare, ver, ok := splitVersion(tc.in) + if bare != tc.bare || ver != tc.ver || ok != tc.ok { + t.Errorf("splitVersion(%q) = %q,%q,%v;想要 %q,%q,%v", + tc.in, bare, ver, ok, tc.bare, tc.ver, tc.ok) + } + } +} + +// 走完整链路:从 Engine 点名要某个版本。 +func TestVersion_从Engine点名(t *testing.T) { + v1 := &tagged{&srcLoader{src: map[string]string{"A": clsOf("v1-A")}}, "v1"} + v2 := &tagged{&srcLoader{src: map[string]string{"A": clsOf("v2-A")}}, "v2"} + + e, err := New(WithLoader(v1, v2)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + call := func(name string) any { + t.Helper() + s, err := e.Script(name) + if err != nil { + t.Fatal(err) + } + got, err := s.Call(t.Context(), "V") + if err != nil { + t.Fatal(err) + } + return got + } + if got := call("A"); got != "v2-A" { + t.Errorf("不点名该拿到上层: %v", got) + } + if got := call("A@v1"); got != "v1-A" { + t.Errorf("点名 v1 该拿到 v1: %v", got) + } + // 两个版本各自缓存,互不干扰 + if got := call("A@v2"); got != "v2-A" { + t.Errorf("点名 v2 该拿到 v2: %v", got) + } +} diff --git a/safety_test.go b/safety_test.go new file mode 100644 index 0000000..e90db0e --- /dev/null +++ b/safety_test.go @@ -0,0 +1,225 @@ +package jscriptx_test + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "git.fsdpf.net/go/jscriptx" + "git.fsdpf.net/go/jscriptx/internal/testext" +) + +// 死循环脚本必须能被超时打断,而不是把调用方的 goroutine 卡死。 +func TestTimeout_打断死循环(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(100*time.Millisecond)) + s := mustCompile(t, e, "loop", `export function spin() { while (true) {} }`) + + start := time.Now() + _, err := s.Call(context.Background(), "spin") + elapsed := time.Since(start) + + if !errors.Is(err, jscriptx.ErrTimeout) { + t.Fatalf("want ErrTimeout, got %v", err) + } + var jsErr *jscriptx.Error + if errors.As(err, &jsErr) && jsErr.Kind != jscriptx.KindTimeout { + t.Errorf("Kind = %q, want timeout", jsErr.Kind) + } + if elapsed > 2*time.Second { + t.Errorf("中断来得太慢: %v", elapsed) + } + t.Logf("%v 后被中断: %v", elapsed, err) +} + +// 调用方主动取消 context 时同样要能立刻停下来。 +func TestTimeout_context取消(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(0)) // 关掉默认超时,只靠 context + s := mustCompile(t, e, "loop", `export function spin() { while (true) {} }`) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + _, err := s.Call(ctx, "spin") + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || jsErr.Kind != jscriptx.KindCanceled { + t.Errorf("Kind 不对: %v", err) + } +} + +// 脚本顶层就死循环时,建 VM 这一步也要受超时保护。 +func TestTimeout_顶层死循环(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(100*time.Millisecond)) + s := mustCompile(t, e, "toploop", `while (true) {}; function f() {}`) + + start := time.Now() + _, err := s.Call(context.Background(), "f") + if !errors.Is(err, jscriptx.ErrTimeout) { + t.Fatalf("want ErrTimeout, got %v", err) + } + t.Logf("%v 后被中断", time.Since(start)) +} + +// 一次超时不能污染 VM 池:紧接着的正常调用必须照常工作。 +func TestTimeout_不污染VM池(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(100*time.Millisecond)) + s := mustCompile(t, e, "mix", ` + export function spin() { while (true) {} } + export function ok() { return "fine" } + `) + + for i := 0; i < 3; i++ { + if _, err := s.Call(context.Background(), "spin"); !errors.Is(err, jscriptx.ErrTimeout) { + t.Fatalf("第 %d 次: want ErrTimeout, got %v", i, err) + } + got, err := s.Call(context.Background(), "ok") + if err != nil { + t.Fatalf("第 %d 次超时之后的正常调用失败了: %v", i, err) + } + if got != "fine" { + t.Fatalf("第 %d 次: got %#v", i, got) + } + } + t.Logf("VM 池统计: %+v", s.Stats()) +} + +// 注入到脚本里的 Go 函数 panic,必须兜成 error,不能掀翻调用方的 goroutine。 +func TestPanic_Go侧回调panic(t *testing.T) { + e := newEngine(t, jscriptx.WithGlobal("explode", func() { panic("Go 侧炸了") })) + s := mustCompile(t, e, "gopanic", `export function run() { explode() }`) + + _, err := s.Call(context.Background(), "run") + if err == nil { + t.Fatal("应该报错而不是 panic 出去") + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) { + t.Fatalf("错误类型不对: %T", err) + } + if jsErr.Kind != jscriptx.KindPanic { + t.Errorf("Kind = %q, want panic", jsErr.Kind) + } + if !strings.Contains(jsErr.Msg, "Go 侧炸了") { + t.Errorf("panic 信息丢了: %q", jsErr.Msg) + } + if jsErr.GoStack == "" { + t.Error("没记录 Go 侧调用栈") + } + + // panic 过的 VM 会被丢弃,下一次调用要能正常工作 + if _, err := s.Call(context.Background(), "run"); err == nil { + t.Error("第二次同样应该报错") + } +} + +// 脚本里的空指针/类型错误是普通 JS 异常,走 KindRuntime。 +func TestPanic_脚本类型错误(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "typeerr", `export function run() { var x = null; return x.foo.bar }`) + + _, err := s.Call(context.Background(), "run") + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || jsErr.Kind != jscriptx.KindRuntime { + t.Fatalf("want KindRuntime, got %v", err) + } + t.Logf("%v", err) +} + +// 无限递归会打爆调用栈,goja 有栈深上限,这里确认它变成普通 error。 +func TestPanic_无限递归(t *testing.T) { + e := newEngine(t, jscriptx.WithMaxCallStackSize(200)) + s := mustCompile(t, e, "recur", `export function run() { return run() }`) + + _, err := s.Call(context.Background(), "run") + if err == nil { + t.Fatal("应该报错") + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || jsErr.Kind != jscriptx.KindRuntime { + t.Fatalf("错误分类不对: %v", err) + } + t.Logf("%v", jsErr.Msg) +} + +// 并发调用同一个脚本:VM 池要保证每个 goroutine 拿到独占的 VM(配合 -race 跑)。 +func TestConcurrent_并发调用(t *testing.T) { + e := newEngine(t, jscriptx.WithMaxVMs(4)) + s := mustCompile(t, e, "concurrent", ` + export function work(state, key, n) { + state.Incr(key, n) + return state.Get(key) + } + `) + + const goroutines = 16 + const perGoroutine = 50 + + // 一份共享状态,所有 goroutine 通过参数传给脚本,验证并发下不丢更新 + shared := testext.New() + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + state := shared + for i := 0; i < perGoroutine; i++ { + if _, err := s.Call(context.Background(), "work", state, "total", 1); err != nil { + t.Errorf("goroutine %d: %v", g, err) + return + } + } + }(g) + } + wg.Wait() + + got := shared.Get("total") + if n, _ := got.(float64); n != goroutines*perGoroutine { + t.Errorf("累加结果 = %#v, want %d", got, goroutines*perGoroutine) + } + t.Logf("VM 池统计: %+v", s.Stats()) +} + +// 并发场景下混着超时调用,确认坏 VM 不会被别的 goroutine 捡到。 +func TestConcurrent_混合超时(t *testing.T) { + e := newEngine(t, jscriptx.WithTimeout(50*time.Millisecond), jscriptx.WithMaxVMs(4)) + s := mustCompile(t, e, "mixed", ` + export function spin() { while (true) {} } + export function ok(n) { return n + 1 } + `) + + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < 10; i++ { + if g%2 == 0 { + if _, err := s.Call(context.Background(), "spin"); !errors.Is(err, jscriptx.ErrTimeout) { + t.Errorf("want ErrTimeout, got %v", err) + return + } + continue + } + got, err := s.Call(context.Background(), "ok", i) + if err != nil { + t.Errorf("正常调用被超时污染了: %v", err) + return + } + if n, _ := got.(int64); n != int64(i+1) { + t.Errorf("got %#v, want %d", got, i+1) + return + } + } + }(g) + } + wg.Wait() + t.Logf("VM 池统计: %+v", s.Stats()) +} diff --git a/scope_pool_test.go b/scope_pool_test.go new file mode 100644 index 0000000..f5ce796 --- /dev/null +++ b/scope_pool_test.go @@ -0,0 +1,172 @@ +package jscriptx + +import ( + "context" + "strings" + "sync" + "testing" +) + +// recorder 是个带状态的扩展,用来验证作用域之间有没有串。 +type recorder struct { + mu sync.Mutex + tag string + seen []string +} + +func (*recorder) Name() string { return "rec" } +func (*recorder) Module() (string, string) { return "", "" } +func (r *recorder) Bindings() map[string]any { + return map[string]any{ + "tag": func() string { return r.tag }, + "note": func(m string) { + r.mu.Lock() + defer r.mu.Unlock() + r.seen = append(r.seen, m) + }, + } +} + +func scoped(t *testing.T, r *recorder) context.Context { + t.Helper() + return WithScope(context.Background(), ScopeExtensions(r)) +} + +// Script.Call 走的是共享池,以前不注入作用域扩展,脚本顶层 import 直接 +// ReferenceError。现在带作用域就单造一个 VM。 +func TestScopePool_池化调用也能拿到扩展(t *testing.T) { + e, _ := New(WithLogger(nil)) + defer e.Close() + + s, err := e.Compile("s.ts", ` + rec.note("顶层:" + rec.tag()) + export default function () { return rec.tag() }`) + if err != nil { + t.Fatal(err) + } + + r := &recorder{tag: "A"} + got, err := s.Call(scoped(t, r), "") + if err != nil { + t.Fatalf("带作用域的池化调用该能用扩展: %v", err) + } + if got != "A" { + t.Errorf("拿到 %#v", got) + } + if len(r.seen) != 1 || r.seen[0] != "顶层:A" { + t.Errorf("模块顶层没跑或没拿到扩展: %v", r.seen) + } +} + +// 关键:带作用域的 VM 用完必须丢,不能回池——回池就把上一个作用域的扩展 +// 漏给下一个调用了。 +func TestScopePool_作用域之间不串(t *testing.T) { + e, _ := New(WithLogger(nil)) + defer e.Close() + + s, _ := e.Compile("s.ts", `export default function () { return rec.tag() }`) + + for _, want := range []string{"A", "B", "C"} { + r := &recorder{tag: want} + got, err := s.Call(scoped(t, r), "") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("拿到的是别的作用域的扩展: 想要 %q,拿到 %#v", want, got) + } + } +} + +// 不带作用域的调用照旧走池,行为不变。 +func TestScopePool_无作用域仍然走池(t *testing.T) { + e, _ := New(WithLogger(nil)) + defer e.Close() + + s, _ := e.Compile("s.ts", `export default function () { return 1 }`) + ctx := context.Background() + + for range 5 { + if _, err := s.Call(ctx, ""); err != nil { + t.Fatal(err) + } + } + // 第一次建一个,后面四次都该命中池 + if st := s.Stats(); st.Dropped != 0 { + t.Errorf("不带作用域不该丢 VM,Dropped = %d", st.Dropped) + } +} + +// 带作用域的 VM 每次都丢,不占池子。 +func TestScopePool_带作用域的VM不回池(t *testing.T) { + e, _ := New(WithLogger(nil)) + defer e.Close() + + s, _ := e.Compile("s.ts", `export default function () { return rec.tag() }`) + + before := s.Stats().Dropped + for range 3 { + if _, err := s.Call(scoped(t, &recorder{tag: "X"}), ""); err != nil { + t.Fatal(err) + } + } + if got := s.Stats().Dropped - before; got != 3 { + t.Errorf("三次带作用域的调用该丢三个 VM,实际丢了 %d", got) + } +} + +// 没有扩展、只有 key 的作用域也不能走池:脚本能读到 scope.key, +// 池化 VM 会把上一个作用域的 key 漏给下一个调用。 +func TestScopePool_只有key的作用域也不走池(t *testing.T) { + e, _ := New(WithLogger(nil)) + defer e.Close() + + s, _ := e.Compile("s.ts", `export default function () { return scope.key }`) + + for _, want := range []string{"k1", "k2", "k3"} { + ctx := WithScope(context.Background(), ScopeKey(want)) + got, err := s.Call(ctx, "") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("拿到的是别的作用域的 key: 想要 %q,拿到 %#v", want, got) + } + } +} + +// 并发下各作用域看到的必须是自己那份。 +func TestScopePool_并发不串(t *testing.T) { + e, _ := New(WithLogger(nil)) + defer e.Close() + + s, _ := e.Compile("s.ts", `export default function () { return rec.tag() }`) + + var wg sync.WaitGroup + errs := make(chan string, 64) + for i := range 32 { + wg.Add(1) + go func() { + defer wg.Done() + want := string(rune('a' + i%26)) + got, err := s.Call(scoped(t, &recorder{tag: want}), "") + if err != nil { + errs <- err.Error() + return + } + if got != want { + errs <- "串了: 想要 " + want + ",拿到 " + got.(string) + } + }() + } + wg.Wait() + close(errs) + + var bad []string + for e := range errs { + bad = append(bad, e) + } + if len(bad) > 0 { + t.Errorf("%d 次出错:\n%s", len(bad), strings.Join(bad, "\n")) + } +} diff --git a/scope_test.go b/scope_test.go new file mode 100644 index 0000000..8fc536e --- /dev/null +++ b/scope_test.go @@ -0,0 +1,433 @@ +package jscriptx_test + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "git.fsdpf.net/go/jscriptx" + "git.fsdpf.net/go/jscriptx/internal/testext" +) + +const deviceScript = ` + export default class DeviceHandler { + constructor(deviceId, model) { + this.deviceId = deviceId + this.model = model + this.count = 0 + } + onMessage(payload) { + this.count++ + return this.deviceId + "#" + this.count + ":" + payload + } + spin() { while (true) {} } + } +` + +// 实例独占 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) + writer := mustCompile(t, e, "writer.ts", ` + export default class Writer { + put(k, v) { store.Set(k, v) } + } + `) + reader := mustCompile(t, e, "reader.ts", ` + export default class Reader { + take(k) { return store.Get(k) } + count() { return store.Len() } + } + `) + + st := testext.New() + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st)) + + w, err := writer.New(ctx) + if err != nil { + t.Fatal(err) + } + defer w.Close() + r, err := reader.New(ctx) + if err != nil { + t.Fatal(err) + } + defer r.Close() + + if _, err := w.Call(ctx, "put", "k", "写进去的"); err != nil { + t.Fatal(err) + } + got, err := r.Call(ctx, "take", "k") + if err != nil { + t.Fatal(err) + } + if got != "写进去的" { + t.Errorf("另一个脚本读不到: %#v", got) + } + + // Go 侧拿的是同一份 + if st.Get("k") != "写进去的" { + t.Errorf("Go 侧读不到: %#v", st.Get("k")) + } + st.Set("fromGo", 1) + if got, _ := r.Call(ctx, "count"); toNumber(t, got) != 2 { + t.Errorf("脚本看不到 Go 侧写的: %#v", got) + } +} + +// 不同作用域之间互不干扰。 +func TestScope_作用域之间隔离(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "counter.ts", ` + export default class C { + bump() { return store.Incr("n", 1) } + } + `) + + st1, st2 := testext.New(), testext.New() + ctx1 := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st1)) + ctx2 := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st2)) + + o1, _ := s.New(ctx1) + defer o1.Close() + o2, _ := s.New(ctx2) + defer o2.Close() + + o1.Call(ctx1, "bump") + o1.Call(ctx1, "bump") + got, err := o2.Call(ctx2, "bump") + if err != nil { + t.Fatal(err) + } + if toNumber(t, got) != 1 { + t.Errorf("第二个作用域被污染: %#v", got) + } +} + +// 用已有的 SharedState 建 store,把 Go 侧正在用的状态直接交给脚本。 +func TestScope_复用已有状态(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "s.ts", ` + export default class S { + read(k) { return store.Get(k) } + } + `) + + state := testext.New() + state.Set("preset", "Go 侧预置的") + + ctx := jscriptx.WithScope(context.Background(), + jscriptx.ScopeExtensions(state)) // Store 本身就是状态,直接当扩展用 + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + if got, _ := obj.Call(ctx, "read", "preset"); got != "Go 侧预置的" { + t.Errorf("got = %#v", got) + } +} + +// 改扩展的全局名。 +func TestScope_扩展改名(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "s.ts", ` + export default class S { + put() { $state.Set("k", "v"); return typeof globalThis.store } + } + `) + + st := testext.Named("$state") + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st)) + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + got, err := obj.Call(ctx, "put") + if err != nil { + t.Fatalf("改名后没生效: %v", err) + } + if got != "undefined" { + t.Errorf("改名后默认名不该还在: %#v", got) + } + if st.Get("k") != "v" { + t.Error("写入没落到同一份") + } +} + +// 没有作用域时,扩展的全局不存在。 +func TestScope_没有作用域就没有扩展(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "s.ts", ` + export default class S { + probe() { return typeof globalThis.store } + } + `) + obj, err := s.New(context.Background()) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + if got, _ := obj.Call(context.Background(), "probe"); got != "undefined" { + t.Errorf("没有作用域不该有 store: %#v", got) + } +} + +// ScopeGlobals 注入作用域专属的全局,脚本能读到会话标识。 +func TestScope_全局与标识(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "s.ts", ` + export default class S { + who() { return currentUser.name + "@" + scope.key } + } + `) + + ctx := jscriptx.WithScope(context.Background(), + jscriptx.ScopeKey("req-7"), + jscriptx.ScopeGlobals(map[string]any{ + "currentUser": map[string]any{"name": "张三"}, + })) + + if key, ok := jscriptx.ScopeKeyOf(ctx); !ok || key != "req-7" { + t.Errorf("ScopeKeyOf = %q, %v", key, ok) + } + + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + if got, _ := obj.Call(ctx, "who"); got != "张三@req-7" { + t.Errorf("got = %#v", got) + } +} + +// 不给 key 时自动生成一个,两次 WithScope 拿到的不一样。 +func TestScope_自动生成key(t *testing.T) { + c1 := jscriptx.WithScope(context.Background()) + c2 := jscriptx.WithScope(context.Background()) + + k1, ok1 := jscriptx.ScopeKeyOf(c1) + k2, ok2 := jscriptx.ScopeKeyOf(c2) + if !ok1 || !ok2 || k1 == "" || k2 == "" { + t.Fatalf("没生成 key: %q %q", k1, k2) + } + if k1 == k2 { + t.Error("两次应该生成不同的 key") + } + + if _, ok := jscriptx.ScopeKeyOf(context.Background()); ok { + t.Error("没有作用域时不该返回 key") + } +} + +// ScopeExtensionOf 在手上没有原对象时把扩展取回来。 +func TestScope_取回扩展(t *testing.T) { + st := testext.New() + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st)) + + got := jscriptx.ScopeExtensionOf(ctx, "store") + if got != jscriptx.Extension(st) { + t.Errorf("取回来的不是同一个: %#v", got) + } + if jscriptx.ScopeExtensionOf(ctx, "nope") != nil { + t.Error("不存在的扩展该返回 nil") + } + if jscriptx.ScopeExtensionOf(context.Background(), "store") != nil { + t.Error("没有作用域时该返回 nil") + } +} + +// 同一个实例的调用串行,保护 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 + } +} diff --git a/script_test.go b/script_test.go new file mode 100644 index 0000000..984009b --- /dev/null +++ b/script_test.go @@ -0,0 +1,238 @@ +package jscriptx_test + +import ( + "context" + "errors" + "strings" + "testing" + + "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", ` + export function add(a, b) { return a + b } + export function greet(name) { return "你好, " + name } + export function nothing() {} + `) + + got, err := s.Call(context.Background(), "add", 2, 3) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if n, ok := got.(int64); !ok || n != 5 { + t.Errorf("add(2,3) = %#v, want int64(5)", got) + } + + got, err = s.Call(context.Background(), "greet", "张三") + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if got != "你好, 张三" { + t.Errorf("greet = %#v", got) + } + + got, err = s.Call(context.Background(), "nothing") + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if got != nil { + t.Errorf("无返回值的函数应该拿到 nil,实际 %#v", got) + } +} + +func TestCall_脚本自身求值出的函数(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "default_fn", ` + export default function handler(x) { return x * 2 } + `) + + got, err := s.Call(context.Background(), jscriptx.DefaultFunc, 21) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if n, _ := got.(int64); n != 42 { + t.Errorf("got = %#v, want 42", got) + } +} + +func TestCallInto_按类型转换(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "typed", ` + export function count() { return 7 } + export function profile() { return {Name: "张三", Age: 30} } + `) + + var n int + if err := s.CallInto(context.Background(), "count", &n); err != nil { + t.Fatalf("调用失败: %v", err) + } + if n != 7 { + t.Errorf("n = %d, want 7", n) + } + + // goja 默认按 Go 字段名映射(不看 json tag),脚本里就得写 Name/Age + var p struct { + Name string + Age int + } + if err := s.CallInto(context.Background(), "profile", &p); err != nil { + t.Fatalf("调用失败: %v", err) + } + if p.Name != "张三" || p.Age != 30 { + t.Errorf("p = %+v", p) + } +} + +func TestCallInto_拒绝非指针目标(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "x", `export function f() { return 1 }`) + + var n int + err := s.CallInto(context.Background(), "f", n) + if err == nil { + t.Fatal("传值不传指针应该报错") + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || jsErr.Kind != jscriptx.KindType { + t.Errorf("错误分类不对: %v", err) + } +} + +// 脚本函数是绑在 VM 上的,VM 一还回池子就失效了,不能让它跨出边界。 +func TestValueEscape_拒绝把脚本函数带出来(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "escape", `export function make() { return function(x) { return x } }`) + + if _, err := s.Call(context.Background(), "make"); !errors.Is(err, jscriptx.ErrValueEscape) { + t.Errorf("Call 返回函数应该被拒绝,实际: %v", err) + } + + var fn func(int) int + err := s.CallInto(context.Background(), "make", &fn) + if !errors.Is(err, jscriptx.ErrValueEscape) { + t.Errorf("CallInto 导出成 Go 函数应该被拒绝,实际: %v", err) + } +} + +func TestCall_函数不存在(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "mod.ts", ` + export const x = 1 + export function other() { return "有这个" } + `) + + _, err := s.Call(context.Background(), "nope") + if !errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Fatalf("want ErrFuncNotFound, got %v", err) + } + if s.Has("nope") { + t.Error("Has 不该认出不存在的名字") + } + if !s.Has("other") { + t.Error("Has 认不出导出的函数") + } + + // 导出的名字存在但不是函数,同样算找不到 + if s.Has("x") { + t.Error("非函数的导出不该被当成函数") + } +} + +func TestCompile_语法错误(t *testing.T) { + e := newEngine(t) + _, err := e.Compile("bad", `export function f( { syntax error`) + if err == nil { + t.Fatal("语法错误应该在编译期报出来") + } + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) || jsErr.Kind != jscriptx.KindCompile { + t.Errorf("错误分类不对: %v", err) + } + if jsErr.Script != "bad" { + t.Errorf("错误里没带上脚本名: %+v", jsErr) + } +} + +func TestError_带定位信息(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "boom.js", ` + export function explode(arg) { + throw new Error("业务校验失败: " + arg) + } + `) + + _, err := s.Call(context.Background(), "explode", "订单号-123") + if err == nil { + t.Fatal("应该报错") + } + + var jsErr *jscriptx.Error + if !errors.As(err, &jsErr) { + t.Fatalf("错误类型不对: %T", err) + } + if jsErr.Kind != jscriptx.KindRuntime { + t.Errorf("Kind = %q", jsErr.Kind) + } + if jsErr.Script != "boom.js" || jsErr.Func != "explode" { + t.Errorf("脚本/函数名不对: %+v", jsErr) + } + if !strings.Contains(jsErr.Msg, "业务校验失败") { + t.Errorf("没带上脚本抛出的信息: %q", jsErr.Msg) + } + if len(jsErr.Stack) == 0 || jsErr.Stack[0].Line != 3 { + t.Errorf("栈信息定位不到出错行: %+v", jsErr.Stack) + } + if len(jsErr.Args) != 1 || !strings.Contains(jsErr.Args[0], "订单号-123") { + t.Errorf("没记录调用参数: %v", jsErr.Args) + } + t.Logf("错误文本: %v", err) +} + +// Go 函数返回的 error 透到脚本里没被 catch 时,调用方的 errors.Is 还应该匹配得上。 +func TestError_透传Go侧哨兵错误(t *testing.T) { + sentinel := errors.New("库存不足") + e := newEngine(t, jscriptx.WithGlobal("checkStock", func() error { return sentinel })) + s := mustCompile(t, e, "stock", `export function run() { checkStock(); return "ok" }`) + + _, err := s.Call(context.Background(), "run") + if !errors.Is(err, sentinel) { + t.Fatalf("原始 Go error 丢了: %v", err) + } +} + +func TestClose_关闭后拒绝调用(t *testing.T) { + e := newEngine(t) + s := mustCompile(t, e, "x", `export function f() { return 1 }`) + + if _, err := s.Call(context.Background(), "f"); err != nil { + t.Fatal(err) + } + s.Close() + if _, err := s.Call(context.Background(), "f"); !errors.Is(err, jscriptx.ErrClosed) { + t.Errorf("want ErrClosed, got %v", err) + } + s.Close() // 重复关闭不应 panic +} diff --git a/static_test.go b/static_test.go new file mode 100644 index 0000000..6a08d9e --- /dev/null +++ b/static_test.go @@ -0,0 +1,229 @@ +package jscriptx_test + +import ( + "context" + "errors" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// ctorSpy 记录 constructor 有没有被跑过。 +type ctorSpy struct{ built, startups int } + +func (s *ctorSpy) Name() string { return "spy" } +func (s *ctorSpy) Module() (string, string) { return "", "" } +func (s *ctorSpy) Bindings() map[string]any { + return map[string]any{ + "built": func() { s.built++ }, + "startup": func() { s.startups++ }, + } +} + +const staticScript = ` +export default class C { + constructor() { spy.built() } + static Startup() { spy.startup(); return "started" } + Execute() { return "executed" } +}` + +func newStaticScript(t *testing.T) (*jscriptx.Engine, *jscriptx.Script, *ctorSpy, context.Context) { + t.Helper() + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(e.Close) + + s, err := e.Compile("c.ts", staticScript) + if err != nil { + t.Fatal(err) + } + spy := &ctorSpy{} + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(spy)) + return e, s, spy, ctx +} + +// 静态方法调得到,而且**不跑 constructor**。 +func TestStatic_调静态方法不构造实例(t *testing.T) { + _, s, spy, ctx := newStaticScript(t) + + got, err := s.CallStatic(ctx, "Startup") + if err != nil { + t.Fatal(err) + } + if got != "started" { + t.Errorf("返回值 %#v", got) + } + if spy.startups != 1 { + t.Errorf("Startup 该跑一次,跑了 %d 次", spy.startups) + } + if spy.built != 0 { + t.Errorf("不该构造实例,constructor 却跑了 %d 次", spy.built) + } +} + +// 静态方法里能用作用域扩展——这正是它的用武之地(配置 Go 侧的东西)。 +func TestStatic_能用作用域扩展(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("c.ts", ` + export default class C { + static Startup() { return spy.tag() } + }`) + if err != nil { + t.Fatal(err) + } + + ext := tagExt{"来自扩展"} + ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(ext)) + + got, err := s.CallStatic(ctx, "Startup") + if err != nil { + t.Fatal(err) + } + if got != "来自扩展" { + t.Errorf("拿到 %#v", got) + } +} + +type tagExt struct{ v string } + +func (tagExt) Name() string { return "spy" } +func (tagExt) Module() (string, string) { return "", "" } +func (e tagExt) Bindings() map[string]any { + return map[string]any{"tag": func() string { return e.v }} +} + +// 没写这个静态方法时要能被 ErrFuncNotFound 认出来——生命周期钩子多半是可选的。 +func TestStatic_没写时报ErrFuncNotFound(t *testing.T) { + _, s, _, ctx := newStaticScript(t) + + _, err := s.CallStatic(ctx, "Shutdown") + if !errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Fatalf("该能被 ErrFuncNotFound 匹配上: %v", err) + } +} + +// 实例方法不算静态方法,别把两者搞混。 +func TestStatic_实例方法不算静态(t *testing.T) { + _, s, spy, ctx := newStaticScript(t) + + _, err := s.CallStatic(ctx, "Execute") + if !errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Fatalf("Execute 是实例方法,不该被静态调用找到: %v", err) + } + if spy.built != 0 { + t.Error("找不到也不该构造实例") + } +} + +// 静态方法抛异常时要把错误报出来,不能跟"没写"混为一谈。 +func TestStatic_抛异常时报错(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("c.ts", ` + export default class C { + static Startup() { throw new Error("配置缺失") } + }`) + if err != nil { + t.Fatal(err) + } + + _, err = s.CallStatic(context.Background(), "Startup") + if err == nil { + t.Fatal("该报错") + } + if errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Error("这是执行出错,不是没写") + } + if !strings.Contains(err.Error(), "配置缺失") { + t.Errorf("错误里该带上脚本抛的内容: %v", err) + } +} + +// 静态方法建的 JS 对象活不下来:VM 用完就丢,后续调用看不到。 +func TestStatic_建的对象活不下来(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("c.ts", ` + let prepared = "没跑过 Startup" + export default class C { + static Startup() { prepared = "Startup 跑过了"; return prepared } + Read() { return prepared } + }`) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + + if got, err := s.CallStatic(ctx, "Startup"); err != nil || got != "Startup 跑过了" { + t.Fatalf("got=%#v err=%v", got, err) + } + + // 换一个 VM 读,看不到 Startup 改的东西 + obj, err := s.New(ctx) + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + got, err := obj.Call(ctx, "Read") + if err != nil { + t.Fatal(err) + } + if got != "没跑过 Startup" { + t.Errorf("静态方法改的顶层变量不该跨 VM 存活,读到 %#v", got) + } +} + +// HasStatic 只认静态方法。 +func TestStatic_HasStatic(t *testing.T) { + _, s, spy, _ := newStaticScript(t) + + if !s.HasStatic("Startup") { + t.Error("Startup 是静态方法,该认出来") + } + if s.HasStatic("Execute") { + t.Error("Execute 是实例方法,不该算静态") + } + if s.HasStatic("Nope") { + t.Error("不存在的名字不该算") + } + if spy.built != 0 { + t.Error("HasStatic 不该构造实例") + } +} + +// 脚本导出的不是 class 时,静态调用要老实说找不到。 +func TestStatic_非class脚本(t *testing.T) { + e, err := jscriptx.New(jscriptx.WithLogger(nil)) + if err != nil { + t.Fatal(err) + } + defer e.Close() + + s, err := e.Compile("c.ts", `export default function () { return 1 }`) + if err != nil { + t.Fatal(err) + } + if s.HasStatic("Startup") { + t.Error("函数导出没有静态方法") + } + if _, err := s.CallStatic(context.Background(), "Startup"); !errors.Is(err, jscriptx.ErrFuncNotFound) { + t.Errorf("该报 ErrFuncNotFound: %v", err) + } +} diff --git a/vmstate_test.go b/vmstate_test.go new file mode 100644 index 0000000..3ae9257 --- /dev/null +++ b/vmstate_test.go @@ -0,0 +1,587 @@ +package jscriptx_test + +import ( + "fmt" + "slices" + "sync" + "testing" + "time" + + "github.com/dop251/goja" +) + +// 这一组直接用 goja,不经过本库的任何封装,钉住整个设计赖以成立的两条引擎事实: +// +// 1. Program 无状态,可以给任意多个 Runtime 共用——本库共用编译结果的前提 +// 2. **模块顶层的变量跟着 Runtime 走,不是共享状态** +// +// 第 2 条是最容易误解的地方。它导致:一个脚本串行开发时表现正常(一直命中同一个 +// Runtime),并发起来行为却随调度漂移——有几个 Runtime 就有几份独立的顶层状态, +// 一次调用落到哪个 Runtime 取决于当时的 goroutine 调度。 +// +// 所以要在并发之间共享状态,只能把它放到 Go 侧(写一个扩展,见 Extension 接口): +// Go 对象可以被每个 Runtime 各自包一层,并发安全由 Go 自己保证。 + +const gojaCounter = ` + var counter = 0; + function bump() { return ++counter } + bump` + +// compileCounter 编一份 Program。它是无状态的,几个用例共用同一份。 +func compileCounter(t *testing.T) *goja.Program { + t.Helper() + prog, err := goja.Compile("counter.js", gojaCounter, false) + if err != nil { + t.Fatal(err) + } + return prog +} + +// bumpOf 在一个新 Runtime 里跑一遍 Program,拿到那个闭包函数。 +func bumpOf(t *testing.T, prog *goja.Program) goja.Callable { + t.Helper() + rt := goja.New() + v, err := rt.RunProgram(prog) + if err != nil { + t.Fatal(err) + } + fn, ok := goja.AssertFunction(v) + if !ok { + t.Fatalf("Program 的完成值不是函数: %v", v) + } + return fn +} + +func callBump(t *testing.T, fn goja.Callable) int64 { + t.Helper() + v, err := fn(goja.Undefined()) + if err != nil { + t.Fatal(err) + } + return v.ToInteger() +} + +// 同一个 Runtime 反复调用,顶层变量一路累加——闭包还在,Program 不会重跑。 +func TestGoja_同一个Runtime里顶层变量会累加(t *testing.T) { + fn := bumpOf(t, compileCounter(t)) + + for i := int64(1); i <= 5; i++ { + if got := callBump(t, fn); got != i { + t.Fatalf("第 %d 次调用该拿到 %d,拿到 %d", i, i, got) + } + } +} + +// 换一个 Runtime 就是另一份顶层变量,哪怕用的是同一个 Program。 +// 这正是「N 个 VM = N 份独立副本」的根源。 +func TestGoja_不同Runtime各有一份顶层变量(t *testing.T) { + prog := compileCounter(t) + + a := bumpOf(t, prog) + b := bumpOf(t, prog) + + for range 3 { // a 先数到 3 + callBump(t, a) + } + if got := callBump(t, b); got != 1 { // b 是全新的 + t.Errorf("另一个 Runtime 该从 1 开始,拿到 %d", got) + } + if got := callBump(t, a); got != 4 { // a 不受影响 + t.Errorf("原来那个 Runtime 该接着数到 4,拿到 %d", got) + } +} + +// Program 可以并发给任意多个 Runtime 用。32 个各调一次,全都拿到 1 +// ——32 份独立的顶层状态,互不影响。 +func TestGoja_Program可以并发共用(t *testing.T) { + prog := compileCounter(t) + + var mu sync.Mutex + var got []int64 + var wg sync.WaitGroup + + for range 32 { + wg.Add(1) + go func() { + defer wg.Done() + rt := goja.New() + v, err := rt.RunProgram(prog) // 同一份 Program,不同 Runtime + if err != nil { + t.Error(err) + return + } + fn, ok := goja.AssertFunction(v) + if !ok { + t.Error("完成值不是函数") + return + } + r, err := fn(goja.Undefined()) + if err != nil { + t.Error(err) + return + } + mu.Lock() + got = append(got, r.ToInteger()) + mu.Unlock() + }() + } + wg.Wait() + + fmt.Println(got) + + if len(got) != 32 { + t.Fatalf("该有 32 个结果,拿到 %d 个", len(got)) + } + for _, v := range got { + if v != 1 { + t.Fatalf("每个 Runtime 都是新的,该全是 1,出现了 %d;全部结果 %v", v, got) + } + } +} + +// 对象不能跨 Runtime 传。这是 goja 的硬限制,也是「JS 内部变量没法并发共享」的根据。 +// +// 维护者在 dop251/goja#232 里说得很直白:非原始类型的 goja.Value 连并发只读都不安全, +// 用 Proxy 包一层也不行,因为 handler 最终还是要访问原来那个值。 +// +// 这条测试是个哨兵:哪天 goja 放开了这个限制,它会失败,提醒我们重新评估 +// 「共享状态必须放 Go 侧」这个结论。 +func TestGoja_对象不能跨Runtime传(t *testing.T) { + rt1 := goja.New() + obj, err := rt1.RunString(`var o = { n: 1 }; o`) + if err != nil { + t.Fatal(err) + } + + rt2 := goja.New() + setErr := rt2.Set("borrowed", obj) // 把 rt1 的对象塞进 rt2 + _, runErr := rt2.RunString(`borrowed.n`) + + if setErr == nil && runErr == nil { + t.Fatal("跨 Runtime 传对象居然成功了——goja 的限制变了," + + "本库关于「共享状态只能放 Go 侧」的结论需要重新评估") + } + t.Logf("Set 的结果: %v", setErr) + t.Logf("读取的结果: %v", runErr) +} + +// 原始值不受这条限制——字符串、数字、布尔不是对象,可以跨 Runtime 传。 +func TestGoja_原始值可以跨Runtime传(t *testing.T) { + rt1 := goja.New() + v, err := rt1.RunString(`"hello"`) + if err != nil { + t.Fatal(err) + } + + rt2 := goja.New() + if err := rt2.Set("s", v); err != nil { + t.Fatalf("原始值该能传: %v", err) + } + got, err := rt2.RunString(`s + " world"`) + if err != nil { + t.Fatal(err) + } + if got.String() != "hello world" { + t.Errorf("拿到 %q", got.String()) + } +} + +// ============================================================================ +// 并发之间要真正共享一个计数器(1,2,3… 不重不漏)该怎么做 +// ============================================================================ + +// sharedCounter 是放在 Go 侧的计数器,并发安全由它自己保证。 +// 每个 Runtime 各自把它包一层——这是 goja 维护者认可的唯一共享方式。 +type sharedCounter struct { + mu sync.Mutex + n int64 +} + +func (c *sharedCounter) Bump() int64 { + c.mu.Lock() + defer c.mu.Unlock() + c.n++ + return c.n +} + +// 方案一:状态放 Go 侧,Runtime 各自独立。**推荐这条。** +// +// 32 个 goroutine 各有各的 Runtime(互不阻塞),但 Set 进去的是同一个 Go 对象, +// 所以 Bump() 拿到的是同一个计数器:结果正好 1..32,不重不漏。 +// +// 只有 Bump 里那把 Go 锁是串行的,脚本执行本身全程并行。 +// 把它包成扩展之后,脚本侧写 store.Incr("counter", 1) 就行——本仓库的 +// internal/testext 是个最小示例,框架层那份在 framework 的 internal/jscript/ext/store。 +func TestGoja_共享状态放Go侧(t *testing.T) { + const n = 32 + + prog, err := goja.Compile("shared.js", ` + function bump() { return shared.Bump() } + bump`, false) + if err != nil { + t.Fatal(err) + } + + counter := &sharedCounter{} // 进程级,只有一个 + got := runConcurrent(t, n, func() (int64, error) { + rt := goja.New() + if err := rt.Set("shared", counter); err != nil { // 同一个指针,各包一层 + return 0, err + } + v, err := rt.RunProgram(prog) + if err != nil { + return 0, err + } + fn, ok := goja.AssertFunction(v) + if !ok { + return 0, fmt.Errorf("完成值不是函数") + } + r, err := fn(goja.Undefined()) + if err != nil { + return 0, err + } + return r.ToInteger(), nil + }) + + fmt.Println("方案一 共享 Go 对象:", got) + assertSequence(t, got, n) +} + +// 方案二:共用一个 Runtime,靠锁串行。 +// +// 这样 JS 里的 counter 是真共享的——因为只有一个 Runtime。代价是**所有脚本执行 +// 完全串行**:脚本跑 1ms,32 个并发就是 32ms 串起来。 +// +// 除非脚本极快且并发很低,否则别走这条。状态放 Go 侧更安全,也不用牺牲并发。 +func TestGoja_共用一个Runtime靠锁串行(t *testing.T) { + const n = 32 + + prog, err := goja.Compile("locked.js", ` + var counter = 0; + function bump() { return ++counter } + bump`, false) + if err != nil { + t.Fatal(err) + } + + rt := goja.New() + v, err := rt.RunProgram(prog) + if err != nil { + t.Fatal(err) + } + fn, ok := goja.AssertFunction(v) + if !ok { + t.Fatal("完成值不是函数") + } + + var rtMu sync.Mutex // Runtime 不是并发安全的,这把锁不能省 + got := runConcurrent(t, n, func() (int64, error) { + rtMu.Lock() + defer rtMu.Unlock() + r, err := fn(goja.Undefined()) + if err != nil { + return 0, err + } + return r.ToInteger(), nil + }) + + fmt.Println("方案二 共用 Runtime:", got) + assertSequence(t, got, n) +} + +// runConcurrent 并发跑 n 次 do,收集结果并排序。 +func runConcurrent(t *testing.T, n int, do func() (int64, error)) []int64 { + t.Helper() + + var mu sync.Mutex + out := make([]int64, 0, n) + var wg sync.WaitGroup + + for range n { + wg.Add(1) + go func() { + defer wg.Done() + v, err := do() + if err != nil { + t.Error(err) + return + } + mu.Lock() + out = append(out, v) + mu.Unlock() + }() + } + wg.Wait() + + slices.Sort(out) + return out +} + +// assertSequence 断言结果正好是 1..n,不重不漏。 +func assertSequence(t *testing.T, got []int64, n int) { + t.Helper() + + if len(got) != n { + t.Fatalf("该有 %d 个结果,拿到 %d 个: %v", n, len(got), got) + } + for i, v := range got { + if v != int64(i+1) { + t.Fatalf("该是 1..%d 的连续序列,第 %d 个是 %d;全部结果 %v", n, i+1, v, got) + } + } +} + +// ============================================================================ +// 脚本里 +1 之后阻塞会怎样:两个方案在这里差别最大 +// ============================================================================ + +const blockOnce = 100 * time.Millisecond + +// sleeper 暴露给脚本的阻塞函数,模拟脚本里做 IO(查库、调接口)。 +func sleeper(d time.Duration) func() { return func() { time.Sleep(d) } } + +// 方案一:计数器在 Go 侧,Runtime 各自独立。 +// +// 脚本先 +1 拿到自己的号,再阻塞。16 个各睡 20ms,总耗时接近 20ms 而不是 320ms +// ——一个脚本卡在 IO 上,不挡别人。而且 +1 在阻塞之前就已经对其他人可见了。 +func TestGoja_阻塞时独立Runtime并行(t *testing.T) { + const n = 16 + + prog, err := goja.Compile("blocking.js", ` + function bump() { + var mine = shared.Bump(); // 先 +1,拿到自己的号 + sleep(); // 再阻塞 + return mine + } + bump`, false) + if err != nil { + t.Fatal(err) + } + + counter := &sharedCounter{} + sleep := sleeper(blockOnce) + start := time.Now() + + got := runConcurrent(t, n, func() (int64, error) { + rt := goja.New() + if err := rt.Set("shared", counter); err != nil { + return 0, err + } + if err := rt.Set("sleep", sleep); err != nil { + return 0, err + } + v, err := rt.RunProgram(prog) + if err != nil { + return 0, err + } + fn, ok := goja.AssertFunction(v) + if !ok { + return 0, fmt.Errorf("完成值不是函数") + } + r, err := fn(goja.Undefined()) + if err != nil { + return 0, err + } + return r.ToInteger(), nil + }) + + elapsed := time.Since(start) + fmt.Printf("方案一 独立 Runtime: %d 个各阻塞 %v,总耗时 %v\n", + n, blockOnce, elapsed.Round(time.Millisecond)) + + fmt.Println(got) + + assertSequence(t, got, n) + + if limit := blockOnce * n / 2; elapsed > limit { + t.Errorf("独立 Runtime 该并行,总耗时 %v 超过了 %v——像是串行了", elapsed, limit) + } +} + +// 方案二:计数器就在 JS 里,共用一个 Runtime。 +// +// 脚本先 counter++(JS 侧的变量,真共享),再阻塞。同样 16 个各睡 20ms, +// 总耗时接近 320ms:Runtime 不是并发安全的,锁必须一直持到脚本返回, +// **脚本在 IO 上卡多久,锁就被占多久**,包括 +1 之后那段。 +// +// 所以"共用 Runtime 换 JS 侧共享状态"的代价不是慢一点,是并发归零。 +func TestGoja_阻塞时共用Runtime串行(t *testing.T) { + const n = 16 + + // 导出两个方法:最后一个表达式是什么,RunProgram 就返回什么。 + // 想导出多个就返回一个对象——注意外面要包一层括号, + // 不然 { 会被当成语句块而不是对象字面量。 + prog, err := goja.Compile("blocking.js", ` + var counter = 0; + function bump() { + counter++; // 先 +1,这是 JS 里的变量,共用 Runtime 才共享得了 + sleep(); // 再阻塞 + return counter + } + function read() { return counter } // 不 +1,只读当前值 + ({ bump: bump, read: read })`, false) + if err != nil { + t.Fatal(err) + } + + rt := goja.New() + if err := rt.Set("sleep", sleeper(blockOnce)); err != nil { + t.Fatal(err) + } + v, err := rt.RunProgram(prog) + if err != nil { + t.Fatal(err) + } + exports := v.ToObject(rt) + fn, ok := goja.AssertFunction(exports.Get("bump")) + if !ok { + t.Fatal("bump 不是函数") + } + read, ok := goja.AssertFunction(exports.Get("read")) + if !ok { + t.Fatal("read 不是函数") + } + + var rtMu sync.Mutex + start := time.Now() + + got := runConcurrent(t, n, func() (int64, error) { + rtMu.Lock() + defer rtMu.Unlock() // 锁持到脚本返回,包括 +1 之后阻塞的那段 + r, err := fn(goja.Undefined()) + if err != nil { + return 0, err + } + return r.ToInteger(), nil + }) + + fmt.Println(got) + + elapsed := time.Since(start) + fmt.Printf("方案二 共用 Runtime: %d 个各阻塞 %v,总耗时 %v\n", + n, blockOnce, elapsed.Round(time.Millisecond)) + + assertSequence(t, got, n) + + if floor := blockOnce * n * 4 / 5; elapsed < floor { + t.Errorf("共用 Runtime 必然串行,总耗时才 %v,不到 %v——是不是没锁住", elapsed, floor) + } + + // 全部并发结束后读最终值。读也要加锁——它跟 bump 用的是同一个 Runtime。 + rtMu.Lock() + final, err := read(goja.Undefined()) + rtMu.Unlock() + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 并发结束后 read(): %d\n", final.ToInteger()) + + if got := final.ToInteger(); got != int64(n) { + t.Errorf("%d 个并发各 +1 一次,最终该是 %d,读到 %d", n, n, got) + } +} + +// 对照:独立 Runtime + JS 侧的 counter++。 +// +// 跟"共用 Runtime"那条用的是**完全相同的脚本**,只有 Runtime 的用法不同。 +// 结果:并行是并行了(~20ms 而不是 320ms),但 16 个全拿到 1 +// ——每个 Runtime 一份自己的 counter,压根没共享。 +// +// 这条和前两条合起来是个 2×2: +// +// JS 侧 counter Go 侧 counter +// 独立 Runtime 全是 1,并行 ✗ 1..16,并行 ✓ +// 共用 Runtime 1..16,串行 ✗ (没必要,白白串行) +// +// 只有右上角那格两头都占。 +func TestGoja_独立Runtime里的JS计数器不共享(t *testing.T) { + const n = 16 + + // 跟 TestGoja_阻塞时共用Runtime串行 用的是同一段脚本 + prog, err := goja.Compile("blocking.js", ` + var counter = 0; + function bump() { + counter++; + sleep(); + return counter + } + function read() { return counter } + ({ bump: bump, read: read })`, false) + if err != nil { + t.Fatal(err) + } + + sleep := sleeper(blockOnce) + + // 把每个 Runtime 的 read 留下来,跑完再各读各的 + var readMu sync.Mutex + var readers []goja.Callable + + start := time.Now() + + got := runConcurrent(t, n, func() (int64, error) { + rt := goja.New() // 每个 goroutine 一个 Runtime + if err := rt.Set("sleep", sleep); err != nil { + return 0, err + } + v, err := rt.RunProgram(prog) + if err != nil { + return 0, err + } + exports := v.ToObject(rt) + fn, ok := goja.AssertFunction(exports.Get("bump")) + if !ok { + return 0, fmt.Errorf("bump 不是函数") + } + read, ok := goja.AssertFunction(exports.Get("read")) + if !ok { + return 0, fmt.Errorf("read 不是函数") + } + + r, err := fn(goja.Undefined()) + if err != nil { + return 0, err + } + + readMu.Lock() + readers = append(readers, read) + readMu.Unlock() + + return r.ToInteger(), nil + }) + + elapsed := time.Since(start) + fmt.Printf("对照 独立 Runtime + JS 计数器: %v 总耗时 %v\n", + got, elapsed.Round(time.Millisecond)) + + // 并行了 + if limit := blockOnce * n / 2; elapsed > limit { + t.Errorf("独立 Runtime 该并行,总耗时 %v 超过了 %v", elapsed, limit) + } + // 但没共享:每个 Runtime 自己的 counter 都是第一次 +1 + for _, v := range got { + if v != 1 { + t.Fatalf("每个 Runtime 一份 counter,该全是 1,出现了 %d;全部结果 %v", v, got) + } + } + + // 全部结束后各读各的:16 个 Runtime,16 份 counter,每份都是 1。 + // 对比共用 Runtime 那条——那边最终读到 16。 + // + // 每个 Runtime 只有创建它的那条路径碰过,此刻也没人在跑,所以读不用加锁。 + finals := make([]int64, 0, len(readers)) + for _, read := range readers { + v, err := read(goja.Undefined()) + if err != nil { + t.Fatal(err) + } + finals = append(finals, v.ToInteger()) + } + fmt.Printf(" 并发结束后各自 read(): %v\n", finals) + + for i, v := range finals { + if v != 1 { + t.Errorf("第 %d 个 Runtime 的 counter 该是 1,读到 %d", i, v) + } + } +}