Files
jscriptx/goja_facts_test.go
what 7c9a70712e 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
挪成外部测试,挪不动。
2026-09-10 15:39:25 +08:00

687 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}