test: 测试文件跟源文件同名,删掉复制粘贴出来的重复

改完之后「某个行为在哪测」就是查同名文件:

  lazyglobal_test.go      → engine_globals_test.go
  static_test.go          → script_static_test.go
  scope_pool_test.go      → script_vm_test.go     它测的是 VM 池,不是作用域
  esmwrap_test.go         → bundle_finalize_test.go
  missing_global_test.go  → errors_hints_test.go
  entry_test.go           → bundle_entry_test.go  并收下 bundle_test.go 里
                                                  那三个 TestEntry_*

删掉 5 组复制粘贴。其中 TestNew_同实例调用串行 和 TestInstance_调用串行 **函数体
完全一致**,只差 mustCompile 的文件名参数 counter.js / counter.ts。Instance 的
测试全部归 instance_test.go 并统一前缀成 TestInstance_,scope_test.go 只留作用域。

vmstate_test.go + govalue_test.go → goja_facts_test.go。这两个测的都是 **goja
本身**,不是这个库:Program 能不能给多个 Runtime 共用、顶层变量跟不跟 Runtime 走、
Go 的切片进到脚本里是不是真数组。它们是「为什么这个库要这么设计」的实验记录,
goja 行为变了会先红。原来那个名字看不出这层定位。

helper 集中到 helper_test.go:newEngine / mustCompile 被十几个文件依赖,原来住在
script_test.go 里,那个名字暗示「测 Script」,找不到。

caller_example_test.go 并进 caller_test.go——同一个类型没必要两个文件。

overlay_test.go 和 script_vm_test.go 保持内部测试,各写了一句为什么:前者要读
Engine.loader 和 isPrepared,后者要直接断言 vmHandle.scoped。我试过把 overlay
挪成外部测试,挪不动。
This commit is contained in:
2026-09-10 15:39:25 +08:00
parent 7e1893b246
commit 7c9a70712e
16 changed files with 427 additions and 572 deletions
+686
View File
@@ -0,0 +1,686 @@
package jscriptx_test
import (
"context"
"fmt"
"slices"
"sync"
"testing"
"time"
"git.fsdpf.net/go/jscriptx"
"github.com/dop251/goja"
)
// 这一组**不测本库,测 goja**——它是「为什么这个库要这么设计」的实验记录。
//
// 三件事:
//
// 1. Program 无状态,可以给任意多个 Runtime 共用——本库共用编译结果的前提
// 2. **模块顶层的变量跟着 Runtime 走,不是共享状态**
// 3. Go 的值进到脚本里长什么样(切片是不是真数组、nil 是 null 还是 undefined
//
// 第 2 条是最容易误解的地方。它导致:一个脚本串行开发时表现正常(一直命中同一个
// Runtime),并发起来行为却随调度漂移——有几个 Runtime 就有几份独立的顶层状态,
// 一次调用落到哪个 Runtime 取决于当时的 goroutine 调度。
//
// 所以要在并发之间共享状态,只能把它放到 Go 侧(写一个扩展,见 Extension 接口):
// Go 对象可以被每个 Runtime 各自包一层,并发安全由 Go 自己保证。
//
// 第 3 条脚本作者是照着写代码的,一旦变了,业务脚本会**静默**走错分支(比如查出来的
// 记录突然 Array.isArray 为 false,整段列表被当成空的)。
//
// 这些行为变了,这一组会先红——那时候要改的是本库的设计,不是这里的断言。
// 其中有一条是哨兵:goja 哪天允许对象跨 Runtime 了,它会失败,提醒我们可以简化设计。
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)
}
}
}
// 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)
}
}
}