Files
thzxx 6a0cfebc4a 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 测试通过。
2026-09-15 01:06:23 +08:00

165 lines
6.3 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>MetonaSqlark E2E Harness</title>
</head>
<body>
<h1>MetonaSqlark E2E Harness</h1>
<div id="status">loading</div>
<script src="/dist/metona-sqlark.js"></script>
<script>
// E2E 驱动器:暴露全局 API,测试通过 page.evaluate 调用
// 使用高层 APIMetonaSqlark.create),保证 e2e 覆盖真实入口路径
window.__ms = {
version: () => (window.MetonaSqlark ? window.MetonaSqlark.VERSION : null),
/**
* 创建并打开 Aria 库。
* opts: {
* name, diskEngine ('opfs'|'memory'),
* walSyncMode, checkpointInterval,
* encryptionPassword, memtableSizeThreshold
* }
*/
open: async (opts) => {
// opts.mode: 'aria'(默认)| 'disk'KVStoreEngine
const config = {
name: opts.name,
mode: opts.mode || 'aria',
diskEngine: opts.diskEngine || 'opfs',
};
if (opts.mode !== 'disk') {
config.aria = {
walSyncMode: opts.walSyncMode || 'full',
checkpointInterval: opts.checkpointInterval || 100000,
memtableSizeThreshold: opts.memtableSizeThreshold || 64 * 1024 * 1024,
...(opts.encryptionPassword ? { encryption: { password: opts.encryptionPassword } } : {}),
};
}
const db = await window.MetonaSqlark.create(config);
window.__ms.__db = db;
return { ok: true, engineName: db.getEngine().name };
},
createTable: async (opts) => {
const db = window.__ms.__db;
const columns = {};
for (const col of opts.columns) {
columns[col.name] = { type: col.type, primaryKey: !!col.primaryKey, index: !!col.index };
}
await db.defineTable(opts.table, columns);
return { ok: true };
},
insert: async (opts) => {
const db = window.__ms.__db;
const pks = await db.table(opts.table).insertMany(opts.rows);
return { pks };
},
count: async (opts) => {
const db = window.__ms.__db;
return { count: await db.table(opts.table).count() };
},
find: async (opts) => {
const db = window.__ms.__db;
const rows = await db.table(opts.table).select().where(opts.where || {}).execute();
return { rows };
},
query: async (opts) => {
const db = window.__ms.__db;
return { rows: await db.query(opts.sql) };
},
repair: async () => {
const db = window.__ms.__db;
await db.repair();
return { ok: true };
},
clearAll: async () => {
const db = window.__ms.__db;
await db.clearAll();
return { ok: true };
},
close: async () => {
const db = window.__ms.__db;
await db.close();
window.__ms.__db = null;
return { ok: true };
},
getEngine: () => (window.__ms.__db ? window.__ms.__db.getEngine() : null),
/**
* v0.8.0PC-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.0PC-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';
</script>
</body>
</html>