系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈
- JSVM3 如何实现函数调用、作用域与声明提升
- JSVM3 如何编译循环、跳转与 try/catch/finally
- 用测试定义解释器的语义边界(本篇)
前四篇从编译、栈帧、作用域和控制流的角度阅读了 JSVM3。第 4 篇最后列出的两个反例将在本文作为测试边界的具体例子使用。这一篇换一个检验方式:不再从 Emitter 已经实现了多少 AST 方法推断能力,而是从测试实际断言了什么来描述边界。
这里的“兼容”指可观察行为:给定源码、宿主上下文和调用方式,解释器产生的返回值、对象状态、异常或副作用是否符合测试预期。AST 节点方法的数量只能说明某些语法分支存在处理路径;它不能说明这条路径经过 Babel 转换、字节码生成、Fiber 调度、宿主对象调用之后仍保持正确语义。
本文的数量均来自本地 /Users/ximing/project/mygithub/jsvm3:以目录下未注释的顶层 Jest it(...) / test(...) 声明为口径统计。随后将 __tests__/es5/、__tests__/es2015/、__tests__/es5-testsuite/、__tests__/case/、__tests__/framework/lodash/ 一起用 npx jest --runInBand 运行,结果为 47 个测试套件、264 个 Jest 测试用例全部通过。本文按顶层 it / test 声明与 Jest 的 Tests 计数讨论用例数,不将其等同于断言数;仅按本次被 Jest 收集的 .test.ts 文件中的 expect( 调用统计,断言约为 396 条。各目录的分项相加也正好是 264。
可解析、可编译、可运行、行为兼容
先把四个层次分开。它们在调试解释器时很容易被混为一谈。
- 可解析:
@babel/parser能否把文本变成 AST。例如import可以被带模块sourceType的解析器接受,并不代表 VM 能执行模块。 - 可编译:
Emitter.visit()能否为最终 AST 生成Script。src/compiler/index.ts的transform()还可能先经过@babel/preset-env与优化插件,所以“源码没有直接落到某个 Emitter 方法”也可能是转换后的结果。 - 可运行:
JSVM.exec()能否让Fiber/Frame跑完,且不抛出未处理错误、超时或挂起。 - 行为兼容:运行结果是否满足特定语义断言。它包括返回值,也包括
this绑定、属性描述符、捕获到的异常、对象是否被原地修改等。
__tests__/helper.ts 的 run() 是现有测试的大多数入口:它以 convertES5 = false 调用 transform(code, 'test.js', { hoisting, convertES5 }),创建 JSVM,执行 vm.exec(script),最后返回 VM 全局对象上的 module.exports。因此这些测试通常同时覆盖“编译并运行”,最终以 expect 校验行为;目录本身没有一套把 parse-only、compile-only、run-only 分离统计的测试。
这也是阅读数据时必须保留的限定:264 个通过测试用例不是“264 个语法节点已实现”的证明,也不能单独量化四个层次各自的覆盖率。例如 __tests__/case/esbuild.test.ts 明确以 convertES5 = true 执行两段 esbuild 输出,证明某些转换后的 ES5 产物可执行;它不能证明原始 ES Module 的 import / export 被 VM 支持。
基础测试以语言构造和语义场景分组
__tests__/es5/ 有 27 个文件、172 个活动用例。目录名覆盖的对象比“一个节点一个文件”更接近实际语义分组:assignment.test.ts、binaryExpression.test.ts、unaryExpression.test.ts、updateExpression.test.ts 处理表达式;loop.test.ts、conditional.test.ts、labeledStatement.test.ts、tryStatement.test.ts 处理语句和控制流;function.test.ts、recursion.test.ts、scope-chain.test.ts、thisExpression.test.ts 处理函数与作用域;另有 prototype.test.ts、object.test.ts、array.test.ts、regExpLiteral.test.ts 等对象模型相关用例。
assignment.test.ts 的一组断言同时验证 =, +=, -=, 位运算赋值和 **=,并检查成员赋值 a.b /= 5 的最终对象状态。这比“AssignmentExpression() 会发射指令”更具体:成员目标求值、旧值读取、运算、写回以及表达式结果至少要在这条路径上配合正确。
__tests__/es5/tryStatement.test.ts 则展示了基础用例的局限与价值。四个用例覆盖正常 try、throw 进 catch、带 catch 的 finally,以及对非函数成员调用后被 catch 捕获。它们验证的都是完整运行结果,但没有覆盖“无 catch 的 try/finally 正常落下”路径。第 4 篇实测过这一路径会在 PAUSE 后空转,所以“同一个 AST 的 TryStatement 已有实现”和“所有 try/finally 语义可靠”是不同结论。
__tests__/es2015/ 的真实情况也应按文件而非目录名描述:目录列出 7 个文件,但其中 5 个以 .test-next.ts 结尾,不匹配当前 Jest 的默认 testMatch,本次运行没有被收集。实际被执行的是 blockScope.test.ts(3 个用例)和 hoisting.test.ts(2 个用例),共 5 个。前者比较 var 和 let 的块作用域,后者覆盖一个块内函数声明提升场景;arrowFunction.test-next.ts、function.test-next.ts 等文件虽存在,不应计入当前可执行测试数。
case 目录保存组合行为的回归证据
基础语法测试适合缩小故障定位范围,但解释器的错误经常出现在多个机制相接的位置。__tests__/case/ 有 7 个文件、13 个活动用例,正是将这类历史场景保留下来。
例如 callback-function.test.ts 有 5 个用例,专门比较命名函数表达式的自引用、形参与函数名遮蔽、回调参数和函数声明绑定。var d = function c() { return c === d; } 的预期是 true;函数体内再声明 var c = 1 时预期变为 false。这里同时涉及函数对象、局部槽位和名称可见性,拆成单个 Identifier 或 FunctionExpression 节点都无法表达完整约束。
chain.test.ts 只有一个断言,执行 d().c() 并确认构造函数式的函数 d 仅执行一次。ins-call.test.ts 则验证经 apply 调用方法时的 this:(d = a)['c'].apply(d, []) 返回对象自己的字段值。它们的规模很小,但目标是保存曾经需要关注的组合路径,适合在修改 CALL、CALLM、左值或作用域实现后回归运行。
因此 case 目录并不是按规范章节分类的完整测试集,也不应据此推导覆盖率;它的价值是把一个具体可观察行为固定为可重复的回归样本。
ES5 Testsuite 检查手写用例遗漏的细节
__tests__/es5-testsuite/ 当前有 7 个文件、39 个活动用例,文件名直接采用 ES5 规范章节号:10.6、11.1、11.4、11.13、12.14、15.2、15.7。从实际内容看,它并不是完整的 ES5 conformance suite,而是从这些章节选取的条目:
10.6.test.ts:arguments对象原型,以及严格模式下形参与arguments[i]不互相映射。11.1.test.ts:数组空位字面量[,]的长度。11.4.test.ts:保留了delete用于字面量、调用结果、对象属性、数组元素和arguments元素的活动用例;涉及严格模式、with或无法通过当前编译管线的规范条目被注释,不计入 39 个活动测试。11.13.test.ts:文件只保留一个keep用例,其余严格模式赋值相关条目被注释。12.14.test.ts:catch 参数、catch 内var/ 函数声明与外层绑定、闭包之间的关系。15.2.test.ts:JSON.parse的函数性、length与属性描述符。15.7.test.ts:Number构造器及其prototype的原型和描述符。
规范条目在这里的作用是补足“写几个加减乘除例子”容易漏掉的语义边界。比如 12.14.test.ts 的 catch 变量既牵涉异常传播,也牵涉函数级 var 提升和词法捕获;15.7.test.ts 又依赖宿主内建对象的原型链。它们仍只是选取的 39 条,不能替代整份规范测试集,更不能覆盖当前已知的 try/finally 正常落下问题。
第三方构建产物与旧 React 实验的证据范围
__tests__/framework/lodash/ 由 4 个 .test.ts 文件组成,35 个活动用例。lodash.ts 在测试初始化时读取 framework/lodash/lodash.out.js,再通过 run() 执行该文件并取得导出的 _;加载时还显式注入 self、global、console、require 与 ArrayBuffer。因此该测试验证的是“特定宿主上下文下,这份 lodash 构建产物可由 JSVM3 执行”,而非在宿主 Node 中直接 import lodash。
具体来说,array.test.ts 断言 chunk、differenceBy、flattenDeep、intersectionWith、unionBy 等数组 API;collection.test.ts 覆盖 countBy、every、groupBy 和 invokeMap;object.test.ts 覆盖带原型实例的 assign、深路径 result 和 set。这些函数会组合数组遍历、回调调用、属性访问、字符串路径和对象创建。通过它们能增加对组合行为的信心,但 35 个 API 示例不是 lodash 的完整兼容性结论,也不能把 lodash 的宿主依赖自动归因于 JSVM3 的语言实现。
大纲提到的 framework/react-case/ 需要特别更正。该目录确实有 src/app.jsx,内容是一个 Greet 组件并调用 react-dom/server 的 renderToString;但 case.ts 读取 dist/out.js 后调用的是 ../../old-src/vm 的 createContext 和 runInContext,不是本系列分析的 src/compiler 与 src/vm,也不位于 __tests__/ 的 Jest 测试集合中。本次按要求运行的 264 个测试用例不包含它。当前 JSVM3 的这一层证据仅来自 lodash 构建产物;React case 用于说明仓库中存在旧 VM 实验,但不纳入当前解释器的兼容性结论。
当前边界必须写进测试结论
测试目录告诉我们哪些行为有证据,也同时暴露无证据或已知失败的区域。
- 无标签
do...while的continue:loop.test.ts有一个continue用例,循环条件是i < 5,VM 仍能得到预期的j = 4。第 4 篇的更小反例将条件缩到i < 2,实测continue跳过条件判断,结果为[3, 2]而非 JavaScript 的[2, 1]。已有绿灯不能推广到所有循环条件。 - 无 catch 的 try/finally 正常落下:
tryStatement.test.ts覆盖的 finally 都带 catch;function f(){ try {} finally {} return 9; }和顶层纯 try/finally 正常落下的实测路径会空转。这是目前运行边界,不能因为TryStatement()已实现就写成完整支持。 with、Generator、模板字符串、ES Module:src/compiler/emitter.ts的WithStatement()、YieldExpression()、TemplateLiteral()、ImportDeclaration()与相关 export 方法均直接抛not implemented。ClassDeclaration()/ClassExpression()也同样没有直接发射路径。transform()在convertES5 = true时可能由 preset-env 先把 class 等语法降成 ES5,__tests__/es5/class.test.ts的通过属于这一转换路径,不能表述为 Emitter 原生支持 Class。现有run()默认convertES5 = false,这一区别需要随调用入口一起说明。
语义证据与 Emitter 状态的对照
| 源码特性 | Emitter 状态 | 测试/执行路径 | 可得结论 |
|---|---|---|---|
| class | ClassDeclaration() / ClassExpression() 直接抛 not implemented |
Babel 降级后 class.test.ts 通过 |
转换产物路径可运行,非原生 Class 受支持 |
| try/finally | TryStatement() 已实现 |
带 catch 的用例通过;无 catch 正常落下有空转反例 | 局部路径受验证,完整语义未受验证 |
| ES Module | parser 可用 sourceType: 'module' |
ImportDeclaration() 抛 not implemented |
可解析不等于可执行 |
为操作码补测试的顺序
操作码的实现通常只是一段局部栈操作;验收应从可观察行为逐层扩展。
先写最小语义:让一段最短源码穿过 Emitter 和新操作码,断言值或状态。随后补边界 case:考虑左值与右值求值顺序、异常、this、作用域、空值和嵌套调用中与该指令相接的路径。接着将修复过的组合场景留在 __tests__/case/,防止同类重构回归。最后再运行规范片段和 lodash 这类较大输入,确认局部改动没有破坏不同的组合行为。
这个顺序不是覆盖率的替代品。它的作用是让每个新增结论都附着在一个可运行的输入和断言上。测试全绿只能证明已执行的输入在给定宿主上下文下满足已有预期;它不能推出未执行路径、未收集文件或被注释条目的行为。测试用例数、expect 断言数和已覆盖的语法特性数分别描述测试组织、检查次数和语义范围,三者不能互相推导。对 JSVM3 而言,“支持 ES5”最多只能指向已经被这些测试证明的语义集合;未被测试覆盖、被注释跳过或已有反例的路径,都应继续作为明确的限制保留。

