2026-10-05 22:22:23 +08:00
|
|
|
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))
|
2026-10-11 17:32:12 +08:00
|
|
|
.replace('void boot();', 'export { state, commitAll, rollbackAll, stageUpdate, removeStaged, ensureEdit, runSql, renderNormalization };');
|
2026-10-05 22:22:23 +08:00
|
|
|
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);
|
|
|
|
|
});
|
2026-10-11 17:32:12 +08:00
|
|
|
|
|
|
|
|
// 校验分析复用编辑视图并保留严格声明的原始字段。
|
|
|
|
|
test('normalization keeps snapshot ownership, exact identities and duplicate declaration fields', async () => {
|
|
|
|
|
await api.beginEdit('normalization-page', 'test');
|
|
|
|
|
const expected = { object: { database_id: '9007199254740993', object_id: '18446744073709551615' }, schema_fingerprint: 'schema' };
|
|
|
|
|
globalThis.fetch = async (url, init) => {
|
|
|
|
|
calls.push({ url, init });
|
|
|
|
|
return response({ api_version: 1, findings: [], schema: [] });
|
|
|
|
|
};
|
|
|
|
|
await api.analyzeNormalization('test', 'users', '{"atomic_attributes":true,"atomic_attributes":false}', expected);
|
|
|
|
|
const sent = calls.find((call) => call.url === '/api/normalization/analyze');
|
|
|
|
|
assert.equal(new Headers(sent.init.headers).get('X-ChuSQL-Edit'), 'normalization-page');
|
|
|
|
|
assert.equal(new Headers(sent.init.headers).get('X-ChuSQL-Database'), 'test');
|
|
|
|
|
assert.equal((sent.init.body.match(/"atomic_attributes"/g) ?? []).length, 2);
|
|
|
|
|
assert.deepEqual(JSON.parse(sent.init.body).expected_object, expected.object);
|
|
|
|
|
assert.equal(calls.filter((call) => call.url === '/api/edit/begin').length, 1);
|
|
|
|
|
api.forgetEdit();
|
|
|
|
|
globalThis.fetch = async (url, init) => {
|
|
|
|
|
calls.push({ url, init });
|
|
|
|
|
return response(url === '/api/query'
|
|
|
|
|
? { columns: [], rows: [], rowCount: 0, truncated: false, transaction: true }
|
|
|
|
|
: { api_version: 1, findings: [], schema: [] });
|
|
|
|
|
};
|
|
|
|
|
await api.query('BEGIN', 'test', 'normalization-console');
|
|
|
|
|
await api.analyzeNormalization('test', 'users', '{}', expected);
|
|
|
|
|
const fromConsole = calls.filter((call) => call.url === '/api/normalization/analyze').at(-1);
|
|
|
|
|
const begun = calls.filter((call) => call.url === '/api/query').at(-1);
|
|
|
|
|
assert.equal(new Headers(fromConsole.init.headers).get('X-ChuSQL-Console'), new Headers(begun.init.headers).get('X-ChuSQL-Console'));
|
|
|
|
|
assert.equal(new Headers(fromConsole.init.headers).get('X-ChuSQL-Edit'), null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 校验结构、违反证据和分解中的用户文字全部转义。
|
|
|
|
|
test('normalization escapes schema names, conditions, witnesses and suggestions', () => {
|
|
|
|
|
const result = { innerHTML: '' };
|
|
|
|
|
const original = document.getElementById;
|
|
|
|
|
document.getElementById = (id) => id === 'normalization-result' ? result : original(id);
|
|
|
|
|
try {
|
|
|
|
|
app.renderNormalization({ schema: [{ name: '<img src=x onerror=alert(1)>', ty: 'str', nullable: true }],
|
|
|
|
|
view: 'live_schema', candidate_keys: [['<script>']],
|
|
|
|
|
findings: [{ normal_form: 'bcnf', verdict: 'violated', condition: '<script>alert(1)</script>', witnesses: [{ lhs: ['<img>'], rhs: ['id'] }] }],
|
|
|
|
|
suggestions: [{ relations: [['<script>'], ['id']] }] });
|
|
|
|
|
assert.equal(result.innerHTML.includes('<img'), false);
|
|
|
|
|
assert.equal(result.innerHTML.includes('<script>'), false);
|
|
|
|
|
assert.equal(result.innerHTML.includes('<img'), true);
|
|
|
|
|
assert.equal(result.innerHTML.includes('结构与约束'), true);
|
|
|
|
|
} finally {
|
|
|
|
|
document.getElementById = original;
|
|
|
|
|
}
|
|
|
|
|
});
|