release: v0.5.1 — 存储后端生产级硬化(CRC-32/全库加密/WAL分片/页面化存储/多标签页锁/e2e)+ 深度审查修复(假实现接线/死代码清理)
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<!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 调用
|
||||
// 使用高层 API(MetonaSqlark.create),保证 e2e 覆盖真实入口路径
|
||||
window.__ms = {
|
||||
version: () => (window.MetonaSqlark ? window.MetonaSqlark.VERSION : null),
|
||||
|
||||
/**
|
||||
* 创建并打开 Aria 库。
|
||||
* opts: {
|
||||
* name, diskEngine ('opfs'|'indexeddb'|'memory'),
|
||||
* walSyncMode, checkpointInterval,
|
||||
* encryptionPassword, memtableSizeThreshold
|
||||
* }
|
||||
*/
|
||||
open: async (opts) => {
|
||||
const db = await window.MetonaSqlark.create({
|
||||
name: opts.name,
|
||||
mode: 'aria',
|
||||
diskEngine: opts.diskEngine || 'opfs',
|
||||
aria: {
|
||||
walSyncMode: opts.walSyncMode || 'full',
|
||||
checkpointInterval: opts.checkpointInterval || 100000,
|
||||
memtableSizeThreshold: opts.memtableSizeThreshold || 64 * 1024 * 1024,
|
||||
...(opts.encryptionPassword ? { encryption: { password: opts.encryptionPassword } } : {}),
|
||||
},
|
||||
});
|
||||
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),
|
||||
};
|
||||
document.getElementById('status').textContent = 'ready';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 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);
|
||||
// 不 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('多标签页锁:第二个标签页打开同一库 → 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MetonaSqlark E2E 静态文件服务器(Playwright webServer 用)
|
||||
* 与 MetonaEditor 的 e2e 服务模式保持一致:Node http 静态服务,不依赖 python3。
|
||||
* 用法: node tests/e2e/server.cjs [port]
|
||||
*/
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const PORT = Number(process.argv[2] || process.env.E2E_PORT || 3344);
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript',
|
||||
'.mjs': 'text/javascript',
|
||||
'.cjs': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let p = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||
// 根路径指向 e2e harness(playwright webServer 探测要求 2xx/3xx)
|
||||
if (p === '/') p = '/tests/e2e/harness.html';
|
||||
const file = path.resolve(ROOT, '.' + p);
|
||||
if (!file.startsWith(ROOT) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
res.writeHead(404); res.end('404 Not Found'); return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'text/plain' });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`E2E server · http://127.0.0.1:${PORT}/`);
|
||||
});
|
||||
Reference in New Issue
Block a user