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 } } // 作用域实例重建 VM 时,要回到它**创建时**那个作用域。 // // Instance 的 VM 超时后会被丢弃,下次调用重建(见 Resets)。重建走的是 i.scope, // 不是当次调用 ctx 里的——对一个作用域实例调 Call(context.Background()) 不该把 // 它的扩展弄丢。 // // 这条守的是 newVM 的 scope 参数为什么是显式传的:改成从 ctx 嗅探就会破坏它。 func TestScope_实例重建VM后仍在原作用域(t *testing.T) { e := newEngine(t, jscriptx.WithTimeout(80*time.Millisecond)) s := mustCompile(t, e, "s.ts", ` export default class S { spin() { while (true) {} } seeStore() { return typeof store } } `) 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() if got, _ := obj.Call(ctx, "seeStore"); got != "object" { t.Fatalf("一开始就没看见扩展: %#v", got) } // 超时会让实例丢掉 VM,下次调用重建 if _, err := obj.Call(ctx, "spin"); !errors.Is(err, jscriptx.ErrTimeout) { t.Fatalf("该超时: %v", err) } if obj.Resets() != 1 { t.Fatalf("VM 没被丢弃,这条测不到重建(Resets=%d)", obj.Resets()) } // 用**不带作用域**的 ctx 触发重建:实例仍属于原来那个作用域 got, err := obj.Call(context.Background(), "seeStore") if err != nil { t.Fatal(err) } if got != "object" { t.Errorf("重建之后扩展丢了(%#v)——实例被当成了无作用域的", got) } }