/** * MetonaSqlark E2E — 真实 Chromium + OPFS 环境验证 * * 覆盖: * 1. OPFS 持久化:写入 → 刷新页面 → 数据保留(跨页面生命周期) * 2. 优雅关闭后重开:数据完整 * 3. 崩溃恢复:写入后强制终止(不 close)→ 重开 → WAL 重放数据完整 * 4. 多标签页锁:页 A 持锁 → 页 B 打开同一库 → ARIA_LOCKED * 5. 加密库密码往返(真实 WebCrypto) * 6. 页面化 + OPFS 端到端 * 7. repair() 自愈(真实文件系统) */ import { test, expect, type Page, type CDPSession } from '@playwright/test'; const HARNESS = '/tests/e2e/harness.html'; async function openPage(page: Page): Promise { await page.goto(HARNESS); await expect(page.locator('#status')).toHaveText('ready'); } /** 在页面中执行 harness 方法(统一包装错误信息) */ async function run(page: Page, method: string, args: unknown = null): Promise { const result = await page.evaluate( (payload: [string, unknown]) => { const [m, a] = payload; return (window as unknown as { __ms: Record Promise> }).__ms[m](a); }, [method, args] as [string, unknown], ); return result as T; } /** * v0.8.0(PC-2):**真崩溃**当前页面。 * * 修复前这里只是 `win.__ms = null; await page.close()` —— 那是**优雅关闭**: * 没有未完成的 I/O、不经过任何崩溃窗口,因此"崩溃恢复"的结论实际上是在 * "正常关闭后重开"上得到的(v0.8.0 审计根因 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); await run(page, 'open', { name: 'e2e-persist', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 'users', columns: [ { name: 'id', type: 'string', primaryKey: true }, { name: 'name', type: 'string' }, { name: 'age', type: 'number' }, ], }); await run(page, 'insert', { table: 'users', rows: [ { id: '1', name: 'Alice', age: 30 }, { id: '2', name: 'Bob', age: 25 }, ], }); expect(await run<{ count: number }>(page, 'count', { table: 'users' })).toEqual({ count: 2 }); // 刷新页面(完全重新加载,OPFS 数据必须保留) await page.reload(); await expect(page.locator('#status')).toHaveText('ready'); await run(page, 'open', { name: 'e2e-persist', diskEngine: 'opfs' }); const res = await run<{ count: number }>(page, 'count', { table: 'users' }); expect(res.count).toBe(2); const found = await run<{ rows: Record[] }>(page, 'find', { table: 'users', where: { id: '1' }, }); expect(found.rows[0].name).toBe('Alice'); await run(page, 'close'); }); test('优雅关闭后重开:数据完整', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-close-reopen', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); for (let i = 0; i < 20; i++) { await run(page, 'insert', { table: 't', rows: [{ id: `r-${i}` }] }); } await run(page, 'close'); await run(page, 'open', { name: 'e2e-close-reopen', diskEngine: 'opfs' }); expect(await run<{ count: number }>(page, 'count', { table: 't' })).toEqual({ count: 20 }); await run(page, 'close'); }); test('崩溃恢复:写入后强制终止 → 重开 WAL 重放数据完整', async ({ page }) => { await openPage(page); // 不 checkpoint(interval 极大),全部写入留在 WAL await run(page, 'open', { name: 'e2e-crash', diskEngine: 'opfs', checkpointInterval: 999999999, }); await run(page, 'createTable', { table: 'logs', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); const total = 50; for (let i = 0; i < total; i++) { await run(page, 'insert', { table: 'logs', rows: [{ id: `log-${i}` }] }); } // v0.8.0(PC-2):**真崩溃**(CDP Page.crash 终止渲染进程,不经过 close/checkpoint) await crashPage(page); // 新页面重开:WAL 重放 const page2 = await page.context().newPage(); await openPage(page2); await run(page2, 'open', { name: 'e2e-crash', diskEngine: 'opfs', checkpointInterval: 999999999, }); 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(); }); test('写入进行中崩溃(不等待完成)→ 重开数据不损坏且可恢复已确认写入', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-crash-mid', diskEngine: 'opfs', checkpointInterval: 999999999, }); await run(page, 'createTable', { table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); // 前 20 条确认完成 for (let i = 0; i < 20; i++) { await run(page, 'insert', { table: 't', rows: [{ id: `c-${i}` }] }); } // 发起一批写入后立即崩溃(不等 Promise 完成) const pending = page.evaluate(async () => { const ms = (window as unknown as { __ms: { insert: (o: unknown) => Promise } }).__ms; const rows = []; for (let i = 0; i < 30; i++) rows.push({ id: `p-${i}` }); await ms.insert({ table: 't', rows }); }); await new Promise((r) => setTimeout(r, 50)); await crashPage(page); await pending.catch(() => {}); // 重开:库可打开,已确认 20 条完整;写入中数据要么全部可见要么部分(日志尾部截断) const page2 = await page.context().newPage(); await openPage(page2); await run(page2, 'open', { name: 'e2e-crash-mid', diskEngine: 'opfs', checkpointInterval: 999999999, }); const res = await run<{ count: number }>(page2, 'count', { table: 't' }); expect(res.count).toBeGreaterThanOrEqual(20); // 已确认的 20 条必须完整 for (let i = 0; i < 20; i++) { const found = await run<{ rows: Record[] }>(page2, 'find', { table: 't', where: { id: `c-${i}` }, }); expect(found.rows).toHaveLength(1); } await run(page2, 'close'); await page2.close(); }); test('checkpoint 前后崩溃 → 快照/日志双路径恢复一致', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-crash-cp', diskEngine: 'opfs', checkpointInterval: 999999999 }); await run(page, 'createTable', { table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); for (let i = 0; i < 10; i++) { await run(page, 'insert', { table: 't', rows: [{ id: `a-${i}` }] }); } // 触发 checkpoint(强制落盘) await run(page, 'repair'); // repair 内含 flush+checkpoint for (let i = 0; i < 10; i++) { await run(page, 'insert', { table: 't', rows: [{ id: `b-${i}` }] }); } // 崩溃(checkpoint 后数据在快照,后续在日志) await crashPage(page); const page2 = await page.context().newPage(); await openPage(page2); await run(page2, 'open', { name: 'e2e-crash-cp', diskEngine: 'opfs', checkpointInterval: 999999999 }); const res = await run<{ count: number }>(page2, 'count', { table: 't' }); expect(res.count).toBe(20); await run(page2, 'close'); await page2.close(); }); test('多标签页锁:第二个标签页打开同一库 → ARIA_LOCKED', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-lock', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 't', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); // 第二个标签页尝试打开同一库 const page2 = await page.context().newPage(); await openPage(page2); const err = await page2.evaluate(async () => { try { await (window as unknown as { __ms: { open: (o: unknown) => Promise } }).__ms.open( { name: 'e2e-lock', diskEngine: 'opfs' }, ); return { ok: true }; } catch (e) { return { ok: false, code: (e as { code?: string }).code, msg: (e as Error).message }; } }); expect(err.ok).toBe(false); expect((err as { code?: string }).code).toBe('ARIA_LOCKED'); // 第一个标签页关闭后 → 第二标签页可打开 await run(page, 'close'); const retry = await page2.evaluate(async () => { const ms = (window as unknown as { __ms: { open: (o: unknown) => Promise; close: () => Promise } }).__ms; await ms.open({ name: 'e2e-lock', diskEngine: 'opfs' }); return { ok: true }; }); expect(retry.ok).toBe(true); await run(page2, 'close'); await page2.close(); }); test('加密库:密码往返(真实 WebCrypto AES-GCM)', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-enc', diskEngine: 'opfs', encryptionPassword: 'correct-horse' }); await run(page, 'createTable', { table: 'secrets', columns: [ { name: 'id', type: 'string', primaryKey: true }, { name: 'payload', type: 'string' }, ], }); await run(page, 'insert', { table: 'secrets', rows: [{ id: 's1', payload: 'top-secret-content' }], }); await run(page, 'close'); // 错误密码 → 打开失败 const page2 = await page.context().newPage(); await openPage(page2); const err = await page2.evaluate(async () => { try { await (window as unknown as { __ms: { open: (o: unknown) => Promise } }).__ms.open( { name: 'e2e-enc', diskEngine: 'opfs', encryptionPassword: 'wrong-password' }, ); return { ok: true }; } catch (e) { return { ok: false, code: (e as { code?: string }).code }; } }); expect((err as { code?: string }).code).toBe('ARIA_DECRYPT_ERROR'); // 正确密码 → 数据完整 await run(page2, 'open', { name: 'e2e-enc', diskEngine: 'opfs', encryptionPassword: 'correct-horse' }); const found = await run<{ rows: Record[] }>(page2, 'find', { table: 'secrets', where: { id: 's1' }, }); expect(found.rows[0].payload).toBe('top-secret-content'); await run(page2, 'close'); await page2.close(); }); test('OPFS 页面化端到端:大数据量 + 二级索引查询', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-paged', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 'docs', columns: [ { name: 'id', type: 'string', primaryKey: true }, { name: 'tag', type: 'string', index: true }, { name: 'body', type: 'string' }, ], }); const total = 100; for (let batch = 0; batch < 5; batch++) { const rows = []; for (let i = 0; i < 20; i++) { const idx = batch * 20 + i; rows.push({ id: `d-${idx}`, tag: idx % 3 === 0 ? 'red' : 'blue', body: '页面化端到端内容'.repeat(30) }); } await run(page, 'insert', { table: 'docs', rows }); } expect(await run<{ count: number }>(page, 'count', { table: 'docs' })).toEqual({ count: total }); // 二级索引查询(真实浏览器中走 LSM 索引) const reds = await run<{ rows: Record[] }>(page, 'find', { table: 'docs', where: { tag: 'red' }, }); expect(reds.rows.length).toBeGreaterThan(0); // SQL 查询 const sql = await run<{ rows: Record[] }>(page, 'query', { sql: 'SELECT COUNT(*) AS n FROM docs WHERE tag = \'red\'', }); expect(Number(sql.rows[0].n)).toBe(reds.rows.length); await run(page, 'close'); }); test('repair() 自愈(真实文件系统)', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-repair', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 't', columns: [ { name: 'id', type: 'string', primaryKey: true }, { name: 'v', type: 'string' }, ], }); await run(page, 'insert', { table: 't', rows: [{ id: 'a', v: 'keep-me' }] }); // 通过 SQL 触发 checkpoint + 手动 repair await run(page, 'repair'); expect(await run<{ count: number }>(page, 'count', { table: 't' })).toEqual({ count: 1 }); // clearAll 后重建表(表结构随 clearAll 清空) await run(page, 'clearAll'); await run(page, 'createTable', { table: 't', columns: [ { name: 'id', type: 'string', primaryKey: true }, { name: 'v', type: 'string' }, ], }); expect(await run<{ count: number }>(page, 'count', { table: 't' })).toEqual({ count: 0 }); await run(page, 'close'); }); }); test.describe('KVStoreEngine(disk 模式)真实环境', () => { test('写入 → 刷新页面 → 数据保留(KVStore 持久化)', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-kv-persist', mode: 'disk', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 'kvusers', columns: [ { name: 'id', type: 'string', primaryKey: true }, { name: 'name', type: 'string' }, { name: 'tag', type: 'string', index: true }, ], }); for (let i = 0; i < 30; i++) { await run(page, 'insert', { table: 'kvusers', rows: [{ id: `u-${i}`, name: `User${i}`, tag: i % 2 === 0 ? 'even' : 'odd' }], }); } expect(await run<{ count: number }>(page, 'count', { table: 'kvusers' })).toEqual({ count: 30 }); // 刷新:KVStore 快照/日志恢复 await page.reload(); await expect(page.locator('#status')).toHaveText('ready'); await run(page, 'open', { name: 'e2e-kv-persist', mode: 'disk', diskEngine: 'opfs' }); const res = await run<{ count: number }>(page, 'count', { table: 'kvusers' }); expect(res.count).toBe(30); // 索引查询(重启重建) const evens = await run<{ rows: Record[] }>(page, 'find', { table: 'kvusers', where: { tag: 'even' }, }); expect(evens.rows.length).toBe(15); await run(page, 'close'); }); test('崩溃恢复:写入后强制终止 → 重开日志重放数据完整', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-kv-crash', mode: 'disk', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 'logs', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); const total = 40; for (let i = 0; i < total; i++) { await run(page, 'insert', { table: 'logs', rows: [{ id: `log-${i}` }] }); } // 模拟崩溃:不 close await crashPage(page); const page2 = await page.context().newPage(); await openPage(page2); await run(page2, 'open', { name: 'e2e-kv-crash', mode: 'disk', diskEngine: 'opfs' }); const res = await run<{ count: number }>(page2, 'count', { table: 'logs' }); expect(res.count).toBe(total); await run(page2, 'close'); await page2.close(); }); test('事务 commit 原子性:多表写入后重启一致', async ({ page }) => { await openPage(page); await run(page, 'open', { name: 'e2e-kv-tx', mode: 'disk', diskEngine: 'opfs' }); await run(page, 'createTable', { table: 't1', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); await run(page, 'createTable', { table: 't2', columns: [{ name: 'id', type: 'string', primaryKey: true }], }); // 用 SQL 多语句触发原子写(事务) await run(page, 'query', { sql: "BEGIN; INSERT INTO t1 VALUES ('a'); INSERT INTO t2 VALUES ('b'); COMMIT;" }); await crashPage(page); const page2 = await page.context().newPage(); await openPage(page2); await run(page2, 'open', { name: 'e2e-kv-tx', mode: 'disk', diskEngine: 'opfs' }); expect(await run<{ count: number }>(page2, 'count', { table: 't1' })).toEqual({ count: 1 }); expect(await run<{ count: number }>(page2, 'count', { table: 't2' })).toEqual({ count: 1 }); await run(page2, 'close'); await page2.close(); }); });