5.2 Playwright 真玩:注入策略、断言状态
上一节产出了一个 9448 字节的单文件 HTML;但「能打开」离「能验证」还差一步。本节做的是 W 层真正的价值:让无头浏览器把整段策略序列打完,再拿它的结果和 Node 侧逐字比对。先说清分工:这不是 《小游戏开发实战》卷一的模拟采样 ——那篇在 S 层跑大量种子、给的是占比类指标与置信区间;本节只跑一局,验证的是宿主等价性:浏览器里跑出来的东西,和命令行里跑出来的,是不是同一个东西。最小可玩原型的产品口径 (做多大、验证什么假设、何时止损)由那一卷负责,本节只负责把它做成可断言的。
预热页不是可选项
Playwright 冷启动时,第一个 page 读 canvas 像素恒为空白——不是画错了,是合成器还没热起来,getImageData 拿到的是全 0。这个坑的可怕之处在于它只影响第一次:反复重跑脚本、偶尔换一台机器,就会得到「有时过有时不过」的假象,而失败点离真正的原因很远。做法是在打开真正页面前,先开一个 about:blank 预热页画一次 8×8 再丢弃:
// snippet: 预热页——先画一次 8×8,再丢弃
const warm = await browser.newPage();
await warm.goto("about:blank");
await warm.evaluate(() => {
const c = document.createElement("canvas");
c.width = 8;
c.height = 8;
c.getContext("2d").getImageData(0, 0, 8, 8).data.length;
});
await warm.close();
同一个道理,canvas 断言只写「已绘制(≥ 1 px)」,绝不写 === 0——后者在预热失败时会假绿,把一个坏掉的渲染当成「没画也是对的」。
注入策略、断言 digest
下面这段是本章的真玩脚本。它做三件事:把规格自带的 25 步策略序列注入页面;断言页面返回的 digest 与 Node 侧 digestSnap(runSpec(spec, seq)) 逐字相同;再换一段 6 步的截断序列跑一遍,确认注入真的被消费(否则页面完全可以用自带策略糊弄过去)。截图与关键数字落 out/mech05_digests.json,供第 5.3 节做三方对照。
// file: web/verify_mech05.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech05.mjs --page out/mech05.html --shot out/shots/mech05.png
//
// 5.2 无头真玩:往页面里**注入整段策略序列**,断言页面返回的 digest 与 Node 侧
// digestSnap(runSpec(spec, seq)) 逐字相同;再断言 canvas 已绘制、两遍确定性、截图落盘。
// 预热页是必需的:Playwright 冷启动首个 page 读 canvas 像素恒为空白。
// 落 out/mech05_digests.json,供第 5.3 节做 Node / 浏览器 / Python 三方对照。
import { createRequire } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
function arg(name, def = null) {
const i = process.argv.indexOf("--" + name);
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
}
// 命令行给相对路径时先转绝对:file:// 只认绝对路径。
const PAGE = resolve(arg("page", join(ROOT, "out", "mech05.html")));
const SHOT = resolve(arg("shot", join(ROOT, "out", "shots", "mech05.png")));
const SPEC_PATH = arg("spec", join(ROOT, "config", "play_spec.json"));
const spec = JSON.parse(readFileSync(SPEC_PATH, "utf8"));
const STRATEGY = spec.strategy.slice(); // 注入的整段策略序列
const ALT = STRATEGY.slice(0, 6); // 截断序列:用来证明注入被真的消费
const nodeDigest = digestSnap(runSpec(spec, STRATEGY));
const nodeAlt = digestSnap(runSpec(spec, ALT));
const fails = [];
const check = (c, m) => {
console.log(` ${c ? "ok " : "FAIL"} ${m}`);
if (!c) fails.push(m);
};
let browserDigest = "";
let browserDigest2 = "";
let browserAlt = "";
let painted = 0;
let bytes = 0;
let played = 0;
const browser = await chromium.launch();
try {
// 预热页:冷启动首个 page 的 canvas 读取恒为空白,先画一次 8×8 再丢弃。
const warm = await browser.newPage();
await warm.goto("about:blank");
await warm.evaluate(() => {
const c = document.createElement("canvas");
c.width = 8;
c.height = 8;
c.getContext("2d").getImageData(0, 0, 8, 8).data.length;
});
await warm.close();
async function open() {
const p = await browser.newPage({ viewport: { width: 900, height: 700 } });
await p.goto("file://" + PAGE);
await p.waitForFunction(
() => globalThis.__ashdeck && globalThis.__ashdeck.canvasDrawn === true,
null,
{ timeout: 15000 },
);
return p;
}
const a = await open();
browserDigest = String(await a.evaluate((seq) => globalThis.__ashdeck.run(seq), STRATEGY));
const snap = await a.evaluate(() => globalThis.__ashdeck.snapshot());
played = snap.played;
painted = await a.evaluate(() => {
const cv = document.querySelector("canvas");
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
let n = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] !== 0) n += 1;
return n;
});
mkdirSync(dirname(SHOT), { recursive: true });
bytes = (await a.screenshot({ path: SHOT })).length;
browserAlt = String(await a.evaluate((seq) => globalThis.__ashdeck.run(seq), ALT));
await a.close();
const b = await open();
browserDigest2 = String(await b.evaluate((seq) => globalThis.__ashdeck.run(seq), STRATEGY));
await b.close();
console.log("=== 第 5.2 节真玩断言 ===");
check(browserDigest === nodeDigest, "页面 digest == Node 侧 digestSnap(runSpec(spec, seq))");
check(browserAlt === nodeAlt, "换一段截断序列,页面 digest 仍与 Node 侧一致(注入生效)");
check(browserAlt !== browserDigest, "换序列确实换了结果(注入被真的消费,不是摆设)");
check(browserDigest === browserDigest2, "两遍独立加载 digest 逐字一致(确定性)");
check(played > 0, `策略序列真的被执行(出牌 ${played} 次)`);
check(snap.cardCount === 12, `牌数守恒 = 12(实为 ${snap.cardCount})`);
check(snap.damageConserved === true, "伤害守恒成立");
check(painted >= 1, `canvas 已绘制(${painted} px >= 1)`);
check(bytes > 1000, `截图已落盘(${bytes} B)`);
} finally {
await browser.close();
}
const report = {
spec: spec.id,
strategyLength: STRATEGY.length,
altLength: ALT.length,
nodeDigest,
browserDigest,
browserDigest2,
nodeAlt,
browserAlt,
played,
painted,
shotBytes: bytes,
page: "out/mech05.html",
shot: "out/shots/mech05.png",
};
writeFileSync(join(ROOT, "out", "mech05_digests.json"), JSON.stringify(report, null, 2) + "\n");
console.log(`NODE_DIGEST=${nodeDigest}`);
console.log(`BROWSER_DIGEST=${browserDigest}`);
console.log(`PAINTED=${painted} SHOT_BYTES=${bytes}`);
console.log(fails.length === 0 ? "OK W 层通过" : `FAIL ${fails.length} 条`);
process.exit(fails.length === 0 ? 0 : 1);
实跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech05.mjs --page out/mech05.html --shot out/shots/mech05.png:
=== 第 5.2 节真玩断言 ===
ok 页面 digest == Node 侧 digestSnap(runSpec(spec, seq))
ok 换一段截断序列,页面 digest 仍与 Node 侧一致(注入生效)
ok 换序列确实换了结果(注入被真的消费,不是摆设)
ok 两遍独立加载 digest 逐字一致(确定性)
ok 策略序列真的被执行(出牌 9 次)
ok 牌数守恒 = 12(实为 12)
ok 伤害守恒成立
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(48443 B)
NODE_DIGEST={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
BROWSER_DIGEST={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
PAINTED=230400 SHOT_BYTES=48443
OK W 层通过
九条全绿,其中三条值得单独读:
- 两条 digest 字符串完全一致,逐字相同(不是「数值接近」):
enemyHp归 0、dealt恰好 50、played9 次、第 4 回合结束、won为真。浏览器与 Node 不只是结论一样,连中间量一样。 - 截断序列给出了不同的 digest:6 步时是
dealt=23、enemyHp=27、turn=2、won=false。两段序列结果不同,说明run(seq)真的用了传进去的参数,而不是忽略它去跑规格自带的strategy。 PAINTED=230400恰好等于 640 × 360,说明整张 canvas 都被填过(背景 + 血条 + 手牌),不是「只有一个像素」的侥幸通过。
截图 out/shots/mech05.png 48443 字节,可以直接贴进评审材料——但它不是这一节的证据,digest 才是。
共享门禁:web/verify_mech.mjs
除了本章专属脚本,还有一个所有章共用的通用门禁 web/verify_mech.mjs(只读复用,本章不修改它)。它只做与章节无关的四件事:两遍独立加载 digest 一致、canvas 已绘制、截图落盘、可选比对 --expect-digest:
# snippet: 共享门禁(只读复用,本章不修改 web/verify_mech.mjs)
D='{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}'
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs \
--page "$PWD/out/mech05.html" --shot "$PWD/out/shots/mech05.png" --expect-digest "$D"
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(48443 B)
ok digest 与 --expect-digest 一致
PAGE_DIGEST={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
OK W 层通过
注意这里必须传绝对路径:脚本内部用 "file://" + PAGE 拼 URL,相对路径会拼出 file://out/mech05.html 这种无效 URL。本章的 verify_mech05.mjs 多做了一步 resolve(),所以相对路径也能跑;调用共享脚本时老老实实给 $PWD。
两遍独立加载不是仪式
脚本开了两次页面、跑了两遍同一个序列,再比对两个 digest。这一步单独存在的理由:同一份代码在同一个进程里跑两次一样,说明不了什么——真正的风险是「跨进程、跨启动」的隐藏状态,比如模块级缓存、全局随机种子,或者某个 Date.now() 混进了结算。两遍独立加载会把这些全部换掉:新的浏览器上下文、新的 JS 引擎实例、新的内存。两遍仍然一致,才排除了「靠进程内残留状态蒙对」这条路径。
再补一条纪律:断言里不写时间、不写性能。截图耗时、加载毫秒数在不同机器上必然不同,把它们写进断言,等于把测试变成一台 flaky 老虎机。
页面 API 与断言的映射
| 页面接口 | 断言拿它做什么 | 为什么够用 |
|---|---|---|
run(seq) 的返回值 | 宿主等价、注入生效、确定性 | 摘要覆盖了全部状态字段 |
snapshot().played | 策略真被执行 | 只看摘要变了不知道「为什么变」 |
snapshot().cardCount | 牌数守恒 | 守恒是规格层不变量,不是渲染属性 |
canvasDrawn | 等待页面就绪 | 比 sleep 稳,比 networkidle 快 |
| canvas 非透明像素 | 渲染没崩 | 只证明「画过」,不证明「画对」 |
最后一行要强调:像素断言只到「已绘制」为止。画面好不好看不是这套要回答的问题——判定观感需要人,属于卷三的可玩性测试;这里只保证「逻辑跑通,并且确实被看见了」。
断言清单
| 断言 | 判据 | 失败意味着 |
|---|---|---|
| 宿主等价 | 页面 digest == Node digest,逐字 | 内核内联错了,或页面没跑注入的序列 |
| 注入生效 | 换序列后 digest 随之改变 | run(seq) 忽略了参数 |
| 确定性 | 两遍独立加载 digest 相同 | 内核里混进了 Math.random() 或时间 |
| 策略真被执行 | played > 0 | 序列被跳过(例如全部 continue) |
| 守恒 | cardCount == 12 且 damageConserved | 抽/弃牌或伤害结算漏了一条 |
| 已绘制 | canvas 非透明像素 ≥ 1 | 渲染报错,或忘了预热页 |
| 截图落盘 | 字节数 > 1000 | 路径没写对或页面是空白 |
七条里前五条都只依赖 digest 与 snapshot(),与像素无关——把渲染和逻辑分开断言,是这套做法的关键:渲染坏了不该让逻辑测试变红,逻辑错了也不该被一张好看的截图掩盖。
下一节把 Node、浏览器、Python 三方的 digest 摆到一起,说清「同源」到底同在哪。
阅读导航:上一节:5.1 规格内联进单文件 HTML · 下一节:5.3 原型与内核的同源性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。