工作流 C-1 / C-3 前半 + 测试代码类型检查。 【故障注入基座】新增 tests/helpers/storage-harness.ts + faulty-backend.ts - TransactionalFileStore:忠实 OPFS 提交语义(close 才可见)+ 字节级故障注入 (failNextWrite/Append/Delete、truncateAppendTo 撕裂写、crashPending 真崩溃) - 删除旧 opfs-mock:读返回内部引用、keepExistingData:false 不截断、close 空实现 导致"提交前可见"等真实缺陷无法被测出(31 个测试文件迁移至新 harness) - 删除 aria-opfs-backend 内的第三份重复 mock(含从未被断言使用的 writeCalls 死代码 与 entry.content.subarray 恒等分支) - FaultyBackend:包装任意 IStorageBackend 注入故障;crash() 明确区别于 close() (后者是优雅停机,会刷完写队列 —— 这正是此前所有"崩溃恢复"测试的真相) - 16 条基座自测证明注入真的生效(含 close 不能当崩溃的对照组) 【覆盖率口径】jest.config.cjs - 移除 '!src/**/index.ts'(该 glob 把 AriaEngine 主实现等 15 个实现文件整体 排除出统计,与 v0.2.6 曾承认过的问题同源),改为只排除纯类型声明文件并附理由 - 新增 coverageThreshold 门禁(此前完全不存在) - 真实基线:语句 90.66% / 分支 82.94% / 函数 94.36% / 行 93.43% - 修正 testMatch 使 tests/helpers 下的测试可被发现 【测试代码类型检查】tsconfig.test.json + npm run typecheck:tests - 修复 103 个测试代码类型错误(此前 babel 剥离类型 + tsconfig 排除 tests,全部隐藏) - 新增 tests/helpers/assertions.ts:nonNull/decode/rows/object/engineMethod/expectCode 以断言收窄替代 as any - 消除 21 个 lint warning(含 v043-hardening 中定义后从未调用的 mockOPFS 死代码) - parser.test.ts 12 处 toBeDefined() 空断言升级为结构断言(并新增 AND/OR 优先级用例, 当前红灯,对应总账第 11 项,将在工作流 A 修复) 【版本契约】新增 tests/version-contract.test.ts - 校验 src VERSION / package.json / dist 三者一致,替代两处硬编码版本字面量 【CI 门禁】.gitea/workflows/ci.yml - lint 去掉 continue-on-error(此前永远不让 CI 变红) - 新增 tests 类型检查、--coverage 覆盖率门禁、dist 与源码同步校验 - 版本 0.7.4 升至 0.8.0
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
/**
|
||
* 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 } from '@playwright/test';
|
||
|
||
const HARNESS = '/tests/e2e/harness.html';
|
||
|
||
async function openPage(page: Page): Promise<void> {
|
||
await page.goto(HARNESS);
|
||
await expect(page.locator('#status')).toHaveText('ready');
|
||
}
|
||
|
||
/** 在页面中执行 harness 方法(统一包装错误信息) */
|
||
async function run<T>(page: Page, method: string, args: unknown = null): Promise<T> {
|
||
const result = await page.evaluate(
|
||
(payload: [string, unknown]) => {
|
||
const [m, a] = payload;
|
||
return (window as unknown as { __ms: Record<string, (o: unknown) => Promise<unknown>> }).__ms[m](a);
|
||
},
|
||
[method, args] as [string, unknown],
|
||
);
|
||
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();
|
||
}
|
||
|
||
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<string, unknown>[] }>(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}` }] });
|
||
}
|
||
// 模拟崩溃:直接终止页面(不调用 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);
|
||
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<unknown> } }).__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<string, unknown>[] }>(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<unknown> } }).__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<unknown>; close: () => Promise<unknown> } }).__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<unknown> } }).__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<string, unknown>[] }>(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<string, unknown>[] }>(page, 'find', {
|
||
table: 'docs', where: { tag: 'red' },
|
||
});
|
||
expect(reds.rows.length).toBeGreaterThan(0);
|
||
|
||
// SQL 查询
|
||
const sql = await run<{ rows: Record<string, unknown>[] }>(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<string, unknown>[] }>(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();
|
||
});
|
||
});
|