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()) } }