From 6a0cfebc4a53294775bc8079b1e54053ce894ea5 Mon Sep 17 00:00:00 2001 From: thzxx Date: Tue, 15 Sep 2026 01:06:23 +0800 Subject: [PATCH] =?UTF-8?q?test(PC-2):=20e2e=20=E7=9C=9F=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=EF=BC=88CDP=20Page.crash=EF=BC=89=E5=8F=96?= =?UTF-8?q?=E4=BB=A3=E5=81=87=E5=B4=A9=E6=BA=83=20+=20=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=20OPFS=20=E5=86=99=E5=85=A5=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景(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 测试通过。 --- .gitea/workflows/ci.yml | 7 ++- tests/e2e/harness.html | 63 ++++++++++++++++++++ tests/e2e/opfs.spec.ts | 126 +++++++++++++++++++++++++++++++++++----- 3 files changed, 182 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0a9e99f..6c167bc 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -101,6 +101,11 @@ jobs: run: npm run build - name: Verify Playwright browsers run: npx playwright install chromium - # v0.8.0: e2e 覆盖真实崩溃注入(CDP Page.crash)与真实 OPFS 语义 + # v0.8.0(PC-2): e2e 用 CDP `Page.crash` **真崩溃**注入,覆盖三类窗口: + # ① 写入进行中崩溃(不等 Promise)② OPFS createWritable 的 write 阶段 + # ③ 提交(close/原子替换)阶段;另含 checkpoint 前后、KVStore WAL 半写、 + # 多标签页锁、加密往返、页面化 + 二级索引、repair 自愈。 + # 注意:e2e 依赖 dist/ 产物(harness.html 加载 /dist/metona-sqlark.js), + # 因此上面的 Build 步骤是必需的;主 job 会校验 dist 与源码同步。 - name: Run E2E tests run: npm run test:e2e diff --git a/tests/e2e/harness.html b/tests/e2e/harness.html index 084bdea..22140c5 100644 --- a/tests/e2e/harness.html +++ b/tests/e2e/harness.html @@ -94,6 +94,69 @@ }, getEngine: () => (window.__ms.__db ? window.__ms.__db.getEngine() : null), + + /** + * v0.8.0(PC-2):**不等写入完成**就返回 —— 供测试在写入进行中触发真崩溃。 + * + * `page.evaluate` 会等待返回的 Promise,因此"发起写入后立刻崩溃"无法用 + * 普通 await 表达。这里把 Promise 挂到 `__ms.__pending` 后立即返回, + * 页面在写入尚未落盘时被杀 → 真正覆盖"WAL 半写 / OPFS 写入中途"窗口。 + */ + insertNoAwait: (opts) => { + const db = window.__ms.__db; + // 故意不 await:这就是"崩溃发生在写入进行中"的语义 + window.__ms.__pending = db.table(opts.table).insertMany(opts.rows); + window.__ms.__pending.catch(() => {}); // 避免未处理拒绝噪音(进程即将被杀) + return { started: true }; + }, + + /** 未完成写入的数量(诊断用) */ + hasPendingWrite: () => ({ pending: !!window.__ms.__pending }), + + /** + * v0.8.0(PC-2):在"下一次 OPFS 写入"处安装崩溃钩子。 + * + * Aria 的 OPFS 后端用 `FileSystemFileHandle.createWritable()`(copy-on-write): + * 数据先写入交换副本,`close()` 时才原子替换原文件。因此"写到一半崩溃" + * 有两种真正不同的窗口,都要能注入: + * - `phase: 'write'` —— 数据写入交换副本的**中途**(`write()` 被调用时卡死); + * - `phase: 'commit'` —— 数据已全部写入交换副本、但**尚未**原子替换 + * (`close()` 被调用时卡死)。这个窗口最关键:此时崩溃必须保证 + * **原文件保持旧内容**(不能出现半个文件)。 + * + * 卡死方式:进入永不返回的同步死循环,随后由 CDP `Page.crash` 终止进程 —— + * 等价于"浏览器在写的瞬间被强杀"。 + * + * 返回 `{ armed, target, phase }`,测试据此断言钩子确实安装成功 + *(避免"注入了但没走到"的假绿)。 + */ + armCrashOnOpfsWrite: (opts) => { + const phase = (opts && opts.phase) || 'write'; + // n 缺省/0 → 第 1 次就崩 + const target = Math.max(1, Number(opts && opts.n) || 1); + const proto = window.FileSystemWritableFileStream && window.FileSystemWritableFileStream.prototype; + if (!proto) return { armed: false, reason: 'FileSystemWritableFileStream unavailable' }; + if (window.__ms.__crashArmed) return { armed: true, alreadyArmed: true, phase }; + window.__ms.__crashArmed = true; + window.__ms.__crashCount = 0; + + const spin = () => { while (true) { /* crash window: Page.crash 在此终止进程 */ } }; + const wrap = (name) => { + const original = proto[name]; + if (typeof original !== 'function') return false; + proto[name] = function patched(...args) { + window.__ms.__crashCount++; + if (window.__ms.__crashCount >= target) spin(); + return original.apply(this, args); + }; + return true; + }; + const hooked = phase === 'commit' ? wrap('close') : wrap('write'); + return { armed: hooked, target, phase }; + }, + + /** 已发生的目标 OPFS 调用次数(验证崩溃窗口确实被触发) */ + opfsWriteCount: () => ({ count: window.__ms.__crashCount || 0 }), }; document.getElementById('status').textContent = 'ready'; diff --git a/tests/e2e/opfs.spec.ts b/tests/e2e/opfs.spec.ts index faff03b..fee0a5e 100644 --- a/tests/e2e/opfs.spec.ts +++ b/tests/e2e/opfs.spec.ts @@ -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(page: Page, method: string, args: unknown = null): Promise return result as T; } -/** 强制"崩溃"当前页面:终止渲染进程(不触发 beforeunload/close) */ -async function crashPage(page: Page): Promise { - 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 { + 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[] }, +): 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[] }>(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[] }>(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[] }>(page2, 'find', { table: 't', where: { id: `ok-${i}` } }); + expect(found.rows).toHaveLength(1); + } await run(page2, 'close'); await page2.close(); });