test: 引擎核心的测试与基准

除了常规用例,有几组是专门钉住「引擎的既定事实」的,改动时会先红:

  - vmstate_test  goja 的硬限制:非原始值不是 goroutine 安全的、对象跨不了
                  Runtime。其中一条是哨兵——goja 哪天允许对象跨 Runtime 了,
                  它会失败,提醒我们可以简化设计。
  - govalue_test  Go 的切片/map 进到脚本里长什么样。这不是本库的行为而是
                  goja 的,但脚本作者照着它写代码,变了会静默走错分支。
  - safety_test   并发下坏 VM 不会被别的 goroutine 捡到。
  - bench_gobind  脚本碰 Go 对象的单次开销,README 性能一节的数据来源。

missing_global_test 里有一条断言 typeof setTimeout === "undefined":
定时器必须保持未定义,库里的特性探测才能正常降级,防止以后有人把桩加回来。
This commit is contained in:
2026-09-05 22:13:57 +08:00
parent 6ce9b483fd
commit 7f484d7a71
20 changed files with 4660 additions and 0 deletions
+120
View File
@@ -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{})
}
+265
View File
@@ -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)
}
}
}
+158
View File
@@ -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)
}
+91
View File
@@ -0,0 +1,91 @@
package jscriptx_test
import (
"context"
"fmt"
"log"
"git.fsdpf.net/go/jscriptx"
)
// Caller 用来实现自定义的调用约定——本库不预设脚本回调该长什么样,
// 这个例子实现的是「回调 + next」这种中间件式的约定:脚本函数写成哪种形状,
// 运行时探测出来自动适配,脚本作者不用额外声明签名。
//
// 它同时也说明了为什么需要 Caller 而不是直接用 Callnext 是个 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 → [输入]
}
+209
View File
@@ -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)
}
}
}
+192
View File
@@ -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 = %#verror 非 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)
}
}
+213
View File
@@ -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)
}
}
+151
View File
@@ -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)
}
}
+96
View File
@@ -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)
}
}
}
+237
View File
@@ -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)
}
}
+280
View File
@@ -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())
}
}
+228
View File
@@ -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" } }`
// 不实现 PreparedEngine 负责打包,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)
}
// 实现了 PreparedEngine 跳过打包,原始 ESM 直接交给 goja 就会语法错误
e2 := newEngine(t, jscriptx.WithLoader(preparedLoader{esmSource}))
if _, err := e2.Script("x"); err == nil {
t.Fatal("声称已打包却给原始 ESM,应该编译失败——说明 Prepared 没被识别,源码被重复打包了")
}
}
+117
View File
@@ -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)
}
}
+419
View File
@@ -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)
}
}
+225
View File
@@ -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())
}
+172
View File
@@ -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("不带作用域不该丢 VMDropped = %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"))
}
}
+433
View File
@@ -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
}
}
+238
View File
@@ -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
}
+229
View File
@@ -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)
}
}
+587
View File
@@ -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 个 Runtime16 份 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)
}
}
}