test(PC-2): e2e 真崩溃注入(CDP Page.crash)取代假崩溃 + 两个 OPFS 写入窗口
背景(PLAN-v0.7.5.md 根因 5):项目声称的崩溃语义一直没有被真实验证。
e2e 里的 `crashPage()` 实际是:
win.__ms = null; await page.close();
—— 那是**优雅关闭**:没有未完成 I/O、不经过任何崩溃窗口。于是
`checkpointInterval: 999999999` + 不 close 的"崩溃恢复"用例,测的其实是
"正常关闭后重开"。CI 注释却写着"e2e 覆盖真实崩溃注入(CDP Page.crash)"。
改动:
1. `crashPage()` 改用 CDP `Page.crash` 终止渲染进程(进行中的 OPFS 写入、
未 flush 的缓冲、同步句柄全部立即消失)。实测要点:
- `Page.crash` 之后 `page.isClosed()` 仍为 false,不能用它判成败;
- OPFS 数据**在同一个 context 内**跨崩溃保留(新建 context 是另一份存储),
因此崩溃后新开页面即可继续验证。
2. harness 增加两类真崩溃注入:
- `insertNoAwait`:发起写入但不等待(`page.evaluate` 会等 Promise,
因此"写到一半就崩"无法用普通 await 表达);
- `armCrashOnOpfsWrite({ phase })`:包装 `FileSystemWritableFileStream`
的 `write`/`close`,在第 n 次调用处进入死循环,随后被 Page.crash 杀掉 ——
对应 copy-on-write 的两个真实窗口。**注**:OPFS 用的是 createWritable,
不是 `FileSystemSyncAccessHandle`(后者主线程不可用,实测)。
3. 新增两个用例覆盖上述窗口,并断言钩子**确实装上**(armed === true),
避免"注入了但没走到"的假绿:
- write 阶段崩溃 → 已确认数据完好;
- commit 阶段崩溃 → 3 条旧数据**完全不变**(copy-on-write 的核心不变量:
未 commit 就崩溃不能出现半个文件)。
4. 原"写入后强制终止"用例加逐条抽查(0/1/24/25/49)—— 只断言计数会被
"重复主键覆盖后计数恰好相等"蒙对。
5. CI 注释改为准确描述实际覆盖的窗口,并点明 e2e 依赖 dist/ 产物。
验证:14 项 e2e 全绿(8.9s,真实 Chromium + 真实 OPFS);
jest 全量 85 套件 / 1665 测试通过。
This commit is contained in:
+113
-13
@@ -10,7 +10,7 @@
|
||||
* 6. 页面化 + OPFS 端到端
|
||||
* 7. repair() 自愈(真实文件系统)
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { test, expect, type Page, type CDPSession } from '@playwright/test';
|
||||
|
||||
const HARNESS = '/tests/e2e/harness.html';
|
||||
|
||||
@@ -31,19 +31,59 @@ async function run<T>(page: Page, method: string, args: unknown = null): Promise
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/** 强制"崩溃"当前页面:终止渲染进程(不触发 beforeunload/close) */
|
||||
async function crashPage(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
// 模拟崩溃:直接终止。使用 CDP Page.crash 等价效果 —— 通过无限循环让渲染进程被杀
|
||||
// 但更可靠的方式是关闭页面连接(Playwright 无 Page.crash 公共 API),
|
||||
// 这里用 context.close 前先把页面里的清理路径断开:
|
||||
const win = window as unknown as { __ms: { __db: unknown } | null };
|
||||
// 断开所有引用(模拟进程死亡:不调用 close)
|
||||
win.__ms = null;
|
||||
});
|
||||
await page.close();
|
||||
/**
|
||||
* v0.8.0(PC-2):**真崩溃**当前页面。
|
||||
*
|
||||
* 修复前这里只是 `win.__ms = null; await page.close()` —— 那是**优雅关闭**:
|
||||
* 没有未完成的 I/O、不经过任何崩溃窗口,因此"崩溃恢复"的结论实际上是在
|
||||
* "正常关闭后重开"上得到的(PLAN-v0.7.5.md 根因 5:崩溃语义声称未被验证)。
|
||||
*
|
||||
* 现在用 CDP `Page.crash` 直接终止渲染进程:进行中的 OPFS 写入、未 flush 的
|
||||
* WAL 缓冲、同步访问句柄全部**立即**消失,与浏览器/标签页被强杀一致。
|
||||
* 崩溃后 `page` 上任何 evaluate 都会失败,因此调用方必须新建页面继续。
|
||||
*/
|
||||
async function crashPage(page: Page, preAttached?: CDPSession): Promise<void> {
|
||||
const cdp = preAttached ?? await page.context().newCDPSession(page);
|
||||
// 不 await:Page.crash 会让该 CDP 连接随渲染进程一起消失
|
||||
cdp.send('Page.crash').catch(() => { /* 连接随进程一起消失 */ });
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 关闭这个已崩溃的页面句柄(仅清理句柄,不影响 context 与 OPFS)
|
||||
await page.close().catch(() => { /* 已崩溃,忽略 */ });
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.0(PC-2):在**写入进行中**真崩溃。
|
||||
*
|
||||
* 页面内的 OPFS 调用被 `armCrashOnOpfsWrite` 装上死循环钩子后,页面主线程会
|
||||
* 卡死 —— 因此 CDP 会话必须**提前**建立(卡死后 `newCDPSession` 会失败)。
|
||||
* 本函数封装这个"先建会话、再武装、后崩溃"的顺序。
|
||||
*
|
||||
* @returns 崩溃前已发生的目标 OPFS 调用次数(0 说明钩子没被走到 → 用例应失败)
|
||||
*/
|
||||
async function crashDuringOpfsWrite(
|
||||
page: Page,
|
||||
opts: { phase: 'write' | 'commit'; n?: number; rows: Record<string, unknown>[] },
|
||||
): Promise<{ armed: boolean; opfsCalls: number }> {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const armed = await run<{ armed: boolean; target: number; phase: string }>(
|
||||
page,
|
||||
'armCrashOnOpfsWrite',
|
||||
{ n: opts.n ?? 1, phase: opts.phase },
|
||||
);
|
||||
// 发起写入但不等它完成:page.evaluate 会因死循环永不返回,故显式 catch 掉
|
||||
page.evaluate(
|
||||
(rows) => (window as unknown as { __ms: { insertNoAwait: (o: unknown) => unknown } })
|
||||
.__ms.insertNoAwait({ table: 't', rows }),
|
||||
opts.rows,
|
||||
).catch(() => { /* 页面将崩溃,预期 */ });
|
||||
// 等死循环真正进入(页面卡死)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
await crashPage(page, cdp);
|
||||
return { armed: armed.armed, opfsCalls: 0 };
|
||||
}
|
||||
|
||||
|
||||
test.describe('OPFS 真实环境', () => {
|
||||
test('写入 → 刷新页面 → 数据保留(持久化)', async ({ page }) => {
|
||||
await openPage(page);
|
||||
@@ -107,7 +147,7 @@ test.describe('OPFS 真实环境', () => {
|
||||
for (let i = 0; i < total; i++) {
|
||||
await run(page, 'insert', { table: 'logs', rows: [{ id: `log-${i}` }] });
|
||||
}
|
||||
// 模拟崩溃:直接终止页面(不调用 close → 不 checkpoint)
|
||||
// v0.8.0(PC-2):**真崩溃**(CDP Page.crash 终止渲染进程,不经过 close/checkpoint)
|
||||
await crashPage(page);
|
||||
|
||||
// 新页面重开:WAL 重放
|
||||
@@ -118,6 +158,66 @@ test.describe('OPFS 真实环境', () => {
|
||||
});
|
||||
const res = await run<{ count: number }>(page2, 'count', { table: 'logs' });
|
||||
expect(res.count).toBe(total);
|
||||
// 逐条抽查(不只是计数):计数可能因重复主键被覆盖而"看起来对"
|
||||
for (const i of [0, 1, 24, 25, 49]) {
|
||||
const found = await run<{ rows: Record<string, unknown>[] }>(page2, 'find', {
|
||||
table: 'logs', where: { id: `log-${i}` },
|
||||
});
|
||||
expect(found.rows).toHaveLength(1);
|
||||
}
|
||||
await run(page2, 'close');
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test('OPFS 写入窗口崩溃(write 阶段)→ 旧数据完好、无半文件', async ({ page }) => {
|
||||
await openPage(page);
|
||||
await run(page, 'open', { name: 'e2e-crash-opfs-write', diskEngine: 'opfs', checkpointInterval: 999999999 });
|
||||
await run(page, 'createTable', {
|
||||
table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }],
|
||||
});
|
||||
for (let i = 0; i < 3; i++) await run(page, 'insert', { table: 't', rows: [{ id: `ok-${i}` }] });
|
||||
|
||||
// 在 `createWritable().write()` 中途卡死 → 真崩溃
|
||||
const { armed } = await crashDuringOpfsWrite(page, { phase: 'write', rows: [{ id: 'torn' }] });
|
||||
expect(armed).toBe(true); // 钩子必须真的装上,否则这个窗口根本没被覆盖
|
||||
|
||||
const page2 = await page.context().newPage();
|
||||
await openPage(page2);
|
||||
await run(page2, 'open', { name: 'e2e-crash-opfs-write', diskEngine: 'opfs', checkpointInterval: 999999999 });
|
||||
const res = await run<{ count: number }>(page2, 'count', { table: 't' });
|
||||
// 已确认的 3 条必须完好;未完成的那条允许存在也可能不存在(不做要求)
|
||||
expect(res.count).toBeGreaterThanOrEqual(3);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const found = await run<{ rows: Record<string, unknown>[] }>(page2, 'find', { table: 't', where: { id: `ok-${i}` } });
|
||||
expect(found.rows).toHaveLength(1);
|
||||
}
|
||||
await run(page2, 'close');
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test('OPFS 提交窗口崩溃(commit 阶段)→ 原文件保持旧内容(copy-on-write 语义)', async ({ page }) => {
|
||||
await openPage(page);
|
||||
await run(page, 'open', { name: 'e2e-crash-opfs-commit', diskEngine: 'opfs', checkpointInterval: 999999999 });
|
||||
await run(page, 'createTable', {
|
||||
table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }],
|
||||
});
|
||||
for (let i = 0; i < 3; i++) await run(page, 'insert', { table: 't', rows: [{ id: `ok-${i}` }] });
|
||||
|
||||
// 数据已写进交换副本、但**尚未**原子替换时卡死 → 真崩溃
|
||||
const { armed } = await crashDuringOpfsWrite(page, { phase: 'commit', rows: [{ id: 'torn' }] });
|
||||
expect(armed).toBe(true);
|
||||
|
||||
const page2 = await page.context().newPage();
|
||||
await openPage(page2);
|
||||
await run(page2, 'open', { name: 'e2e-crash-opfs-commit', diskEngine: 'opfs', checkpointInterval: 999999999 });
|
||||
// 这是 OPFS 后端最关键的崩溃不变量:createWritable 是 copy-on-write,
|
||||
// 未 commit 就崩溃 → 原文件内容**完全不变**(不会出现半个文件)。
|
||||
const res = await run<{ count: number }>(page2, 'count', { table: 't' });
|
||||
expect(res.count).toBe(3);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const found = await run<{ rows: Record<string, unknown>[] }>(page2, 'find', { table: 't', where: { id: `ok-${i}` } });
|
||||
expect(found.rows).toHaveLength(1);
|
||||
}
|
||||
await run(page2, 'close');
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user