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