Files
MetonaSqlark/tests/e2e/opfs.spec.ts
T
thzxx a9e1a281d3
CI / test (20.x) (push) Successful in 10m51s
CI / test (18.x) (push) Successful in 10m59s
CI / test (22.x) (push) Successful in 10m47s
CI / test (24.x) (push) Successful in 10m41s
CI / e2e (push) Successful in 9m52s
fix: v0.6.0 复查修正 — KVStore 快照损坏水位 bug(metaSeq 误跳日志)+ 快照损坏测试盲区修复(此前篡改未生效)+ metaSeq 回归测试 + KVStoreEngine 真实浏览器 e2e(3 用例)+ 版本/配置文档校准
2026-08-10 14:11:44 +08:00

409 lines
16 KiB
TypeScript
Raw 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.
/**
* 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): Promise<T> {
const result = await page.evaluate(
([m, a]) => (window as unknown as { __ms: Record<string, (o: unknown) => Promise<unknown>> }).__ms[m](a),
[method, args] as const,
);
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);
// 不 checkpointinterval 极大),全部写入留在 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('KVStoreEnginedisk 模式)真实环境', () => {
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();
});
});