176 lines
8.3 KiB
JavaScript
176 lines
8.3 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { beforeEach, test } from 'node:test';
|
|
import * as api from '../static/api.js';
|
|
import { createEmptyChangeSet } from '../static/core.js';
|
|
|
|
// 验证页面编辑状态、原子请求、断线重试与关闭清理。
|
|
|
|
const listeners = new Map();
|
|
globalThis.window = { addEventListener: (name, callback) => listeners.set(name, callback), dispatchEvent: () => {} };
|
|
globalThis.document = { getElementById: (id) => id === 'root' ? { addEventListener() {} } : null };
|
|
let source = await readFile(new URL('../static/app.js', import.meta.url), 'utf8');
|
|
source = source.replaceAll("'./api.js'", JSON.stringify(new URL('../static/api.js', import.meta.url).href))
|
|
.replaceAll("'./core.js'", JSON.stringify(new URL('../static/core.js', import.meta.url).href))
|
|
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql };');
|
|
const app = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
|
|
let calls;
|
|
let commitResponse;
|
|
|
|
// 生成 JSON HTTP 响应。
|
|
function response(body, status = 200) {
|
|
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
// 重置页面与模拟服务端。
|
|
beforeEach(() => {
|
|
calls = [];
|
|
commitResponse = () => response({ ok: true, applied: 1, state: 'committed' });
|
|
api.forgetEdit();
|
|
Object.assign(app.state, { database: 'test', databases: ['test'], tabs: [], activeId: undefined,
|
|
view: 'workbench', session: { user: 'admin' }, edit: { id: 'page', database: 'test' },
|
|
editBusy: false, editStarting: null, editAttempt: null, editNeedsReload: false, editUncertain: null,
|
|
changes: createEmptyChangeSet(), messages: [] });
|
|
globalThis.fetch = async (url, init) => {
|
|
calls.push({ url, init, body: init.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/edit/commit') return commitResponse();
|
|
if (url === '/api/databases') return response(['test']);
|
|
if (url === '/api/tables') return response([]);
|
|
return response({ state: 'active' });
|
|
};
|
|
});
|
|
|
|
// 检查整批修改只发送一次提交请求。
|
|
test('all table edits use one atomic request and clear only after success', async () => {
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
|
app.stageUpdate('second', 'test', 2, 'value', 20, 30);
|
|
await app.commitAll();
|
|
const committed = calls.filter((call) => call.url === '/api/edit/commit');
|
|
assert.equal(committed.length, 1);
|
|
assert.equal(committed[0].body.changes.length, 2);
|
|
assert.equal(app.state.changes.updates.size, 0);
|
|
assert.equal(app.state.edit, null);
|
|
assert.equal(calls.some((call) => call.init.method === 'PATCH'), false);
|
|
});
|
|
|
|
// 检查失败保留可修正的完整编辑集。
|
|
test('constraint failure retains all edits and permits local undo before replay', async () => {
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
|
app.stageUpdate('second', 'test', 2, 'value', 20, 30);
|
|
commitResponse = () => response({ ok: false, applied: 0, state: 'active', message: 'constraint', reloadRequired: false }, 400);
|
|
await app.commitAll();
|
|
assert.equal(app.state.changes.updates.size, 2);
|
|
assert.equal(app.state.editNeedsReload, false);
|
|
app.removeStaged([...app.state.changes.updates.keys()][0]);
|
|
assert.equal(app.state.changes.updates.size, 1);
|
|
assert.equal(calls.some((call) => call.init.method === 'PATCH'), false);
|
|
});
|
|
|
|
// 检查冲突禁止再次提交直到回滚。
|
|
test('serialization conflict freezes edits and requires rollback and reload', async () => {
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
|
commitResponse = () => response({ ok: false, state: 'rolled_back', reloadRequired: true, message: 'conflict' }, 409);
|
|
await app.commitAll();
|
|
await app.commitAll();
|
|
assert.equal(calls.filter((call) => call.url === '/api/edit/commit').length, 1);
|
|
assert.equal(app.state.changes.updates.size, 1);
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 40);
|
|
assert.equal([...app.state.changes.updates.values()][0].newValue, 20);
|
|
await app.rollbackAll();
|
|
assert.equal(app.state.changes.updates.size, 0);
|
|
assert.equal(app.state.editNeedsReload, false);
|
|
assert.equal(calls.some((call) => call.url === '/api/edit/rollback'), true);
|
|
});
|
|
|
|
// 检查断线后冻结载荷并使用同一提交标识。
|
|
test('an uncertain response retries the same payload without allowing edits', async () => {
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
|
commitResponse = () => { throw new Error('connection lost'); };
|
|
await app.commitAll();
|
|
assert.ok(app.state.editUncertain);
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 99);
|
|
commitResponse = () => response({ ok: true, applied: 1, state: 'committed' });
|
|
await app.commitAll();
|
|
const committed = calls.filter((call) => call.url === '/api/edit/commit');
|
|
assert.deepEqual(committed[0].body, committed[1].body);
|
|
assert.equal(app.state.editUncertain, null);
|
|
});
|
|
|
|
// 检查并发点击只有一个正在提交的请求。
|
|
test('duplicate clicks cannot submit concurrently', async () => {
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
|
await Promise.all([app.commitAll(), app.commitAll()]);
|
|
assert.equal(calls.filter((call) => call.url === '/api/edit/commit').length, 1);
|
|
});
|
|
|
|
// 检查快照读取带页面标识且普通 SQL 无标识。
|
|
test('snapshot headers are attached only to table reads', async () => {
|
|
await api.beginEdit('page', 'test');
|
|
await api.listTables('test');
|
|
globalThis.fetch = async (url, init) => {
|
|
calls.push({ url, init });
|
|
return response({ columns: [], rows: [], rowCount: 0 });
|
|
};
|
|
await api.query('SELECT 1', 'test');
|
|
assert.equal(calls.find((call) => call.url === '/api/tables').init.headers.get('X-ChuSQL-Edit'), 'page');
|
|
assert.equal(calls.find((call) => call.url === '/api/query').init.headers.has('X-ChuSQL-Edit'), false);
|
|
});
|
|
|
|
// 检查页面离开时发送回滚请求。
|
|
test('page close sends a rollback beacon for its own transaction', async () => {
|
|
let beacon;
|
|
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { sendBeacon: (url, body) => { beacon = { url, body }; } } });
|
|
listeners.get('pagehide')();
|
|
assert.equal(beacon.url, '/api/edit/rollback');
|
|
assert.deepEqual(JSON.parse(await beacon.body.text()), { id: 'page', database: 'test' });
|
|
});
|
|
|
|
// 检查 BEGIN 回包断线后仍使用同一标识。
|
|
test('a lost BEGIN response keeps its identity for an idempotent retry', async () => {
|
|
app.state.edit = null;
|
|
globalThis.fetch = async (url, init) => {
|
|
calls.push({ url, init, body: JSON.parse(init.body) });
|
|
if (calls.length === 1) throw new Error('begin response lost');
|
|
return response({ state: 'active' });
|
|
};
|
|
await assert.rejects(app.ensureEdit('test'));
|
|
assert.ok(app.state.editAttempt);
|
|
await app.ensureEdit('test');
|
|
assert.deepEqual(calls[0].body, calls[1].body);
|
|
assert.equal(app.state.edit.id, calls[0].body.id);
|
|
});
|
|
|
|
// 检查每个 SQL 标签使用独立所有者标识。
|
|
test('console transaction cleanup only applies to its owning tab', async () => {
|
|
globalThis.fetch = async (url, init) => {
|
|
calls.push({ url, init, body: JSON.parse(init.body) });
|
|
return response({ columns: [], rows: [], rowCount: 0, transaction: true });
|
|
};
|
|
await api.query('BEGIN', 'test', 'first');
|
|
const owner = calls[0].init.headers.get('X-ChuSQL-Console');
|
|
assert.ok(owner.endsWith(':first'));
|
|
await api.rollbackConsole('second');
|
|
assert.equal(calls.length, 1);
|
|
await api.rollbackConsole('first');
|
|
assert.equal(calls[1].url, '/api/query/rollback');
|
|
assert.equal(calls[1].body.console, owner);
|
|
});
|
|
|
|
// 检查 SQL 回滚等待期间不能继续编辑旧快照。
|
|
test('edits are locked while SQL releases the previous snapshot', async () => {
|
|
let release;
|
|
globalThis.fetch = async (url, init) => {
|
|
calls.push({ url, init });
|
|
if (url === '/api/edit/rollback') return new Promise((resolve) => { release = () => resolve(response({ state: 'rolled_back' })); });
|
|
return response({ columns: ['value'], rows: [[1]], rowCount: 1 });
|
|
};
|
|
const job = app.runSql({ id: 'query', database: 'test', sql: 'SELECT 1' });
|
|
assert.equal(app.state.editBusy, true);
|
|
app.stageUpdate('first', 'test', 1, 'value', 10, 20);
|
|
assert.equal(app.state.changes.updates.size, 0);
|
|
release();
|
|
await job;
|
|
assert.equal(app.state.editBusy, false);
|
|
assert.equal(app.state.edit, null);
|
|
});
|