Files
syncdb/tests/export-progress-ui.test.cjs
2026-09-27 16:38:27 +08:00

118 lines
5.5 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const app = fs.readFileSync(path.join(__dirname, "../cmd/syncdb/web/app.js"), "utf8");
function section(start, end) {
const from = app.indexOf(start), to = app.indexOf(end, from);
assert.ok(from >= 0 && to > from, start);
return app.slice(from, to);
}
const script = [
section("const translations =", "const pages ="),
section("function exportProgressHTML(", "function filtered("),
section("function applySnapshot(", "function startUpdates(")
].join("\n");
function harness(locale = "en") {
const elements = new Map();
const element = id => {
if (!elements.has(id)) elements.set(id, { open: true, dataset: {}, innerHTML: "" });
return elements.get(id);
};
const state = { exports: [] };
const context = vm.createContext({
document: { getElementById: element }, localStorage: { getItem: () => locale },
state, editorKind: "detail", submittedCSVExports: new Map(), cancellingCSVExports: new Set(), lastSnapshot: 0,
date: value => value || "—", sourceName: value => value, exportKind: () => "Binlog CSV", exportMessages: () => "",
detailHTML: () => "", notice: value => value,
actionGroup: (...values) => values.join(""),
button: (label, action, id, disabled) => `<button data-action="${action}" data-id="${id}" ${disabled ? "disabled" : ""}>${label}</button>`,
listResult: value => { assert.ok(Array.isArray(value)); return value; },
syncFilterOptions() {}, hasAuditFilters: () => false,
render: () => context.refreshExportDetail()
});
vm.runInContext(script, context);
return { context, state, element };
}
function record(status = "RUNNING", overrides = {}) {
return { id: "ex-1", source_id: "ds-1", status, format: "csv", mode: "binlog", progress: {
file: "mysql-bin.000123", files_total: 3, files_completed: 1, scanned_events: 1200,
scanned_bytes: 300, total_bytes: 1000, matched_events: 40, output_bytes: 80, updated_at: "2026-09-25T08:00:00Z", ...overrides
} };
}
test("progress presents current file, scan counts, matching counts and output size", () => {
const ui = harness();
const html = ui.context.exportProgressHTML(record());
assert.match(html, /value="30"/);
assert.match(html, /30%/);
for (const value of ["mysql-bin.000123", "Files scanned: 1 / 3", "Events scanned: 1,200", "Matching events: 40", "Bytes scanned: 300 B \/ 1,000 B", "Output size: 80 B"]) {
assert.ok(html.includes(value), value);
}
});
test("100 percent is reserved for a successfully saved export", () => {
const ui = harness();
for (const status of ["RUNNING", "FAILED", "CANCELLED"]) {
const html = ui.context.exportProgressHTML(record(status, { scanned_bytes: 1000 }));
assert.match(html, /value="99"/);
assert.doesNotMatch(html, /<strong>100%/);
if (status === "RUNNING") assert.match(html, /finishing file storage/);
}
assert.match(ui.context.exportProgressHTML(record("COMPLETED")), /value="100"/);
});
test("unknown totals do not display an invented percentage, and file names are escaped", () => {
const ui = harness();
const html = ui.context.exportProgressHTML(record("RUNNING", { total_bytes: 0, file: '<img src=x onerror="alert(1)">' }));
assert.match(html, /Waiting for scan progress/);
assert.doesNotMatch(html, /<progress|<img/);
assert.match(html, /&lt;img/);
assert.doesNotMatch(ui.context.exportProgressHTML(record("RUNNING", { total_bytes: NaN, scanned_events: Infinity })), /NaN|Infinity/);
});
test("SSE snapshots refresh an open export detail and enable download only after completion", () => {
const ui = harness();
ui.element("dialog").dataset.exportId = "ex-1";
const snapshot = item => ({ sources: [], tasks: [], executions: [], exports: [item], audits: [] });
ui.context.applySnapshot(snapshot(record()));
assert.match(ui.element("fields").innerHTML, /value="30"/);
assert.match(ui.element("fields").innerHTML, /data-action="cancelExport"/);
assert.doesNotMatch(ui.element("fields").innerHTML, /href="\/api\/exports\/ex-1\/download"/);
ui.context.applySnapshot(snapshot(record("RUNNING", { scanned_bytes: 800 })));
assert.match(ui.element("fields").innerHTML, /value="80"/);
ui.context.applySnapshot(snapshot(record("COMPLETED", { scanned_bytes: 1000 })));
assert.match(ui.element("fields").innerHTML, /value="100"/);
assert.match(ui.element("fields").innerHTML, /href="\/api\/exports\/ex-1\/download"/);
assert.doesNotMatch(ui.element("fields").innerHTML, /data-action="cancelExport"/);
});
test("snapshot updates do not overwrite another editor or a closed dialog", () => {
const ui = harness();
ui.element("dialog").dataset.exportId = "ex-1";
ui.state.exports = [record()];
ui.element("fields").innerHTML = "editing-source";
ui.context.editorKind = "editSource";
ui.context.refreshExportDetail();
assert.equal(ui.element("fields").innerHTML, "editing-source");
ui.context.editorKind = "detail";
ui.element("dialog").open = false;
ui.context.refreshExportDetail();
assert.equal(ui.element("fields").innerHTML, "editing-source");
});
test("progress labels are available in all supported languages", () => {
for (const locale of ["zh", "en", "ja"]) {
const ui = harness(locale);
const html = ui.context.exportProgressHTML(record());
assert.doesNotMatch(html, /exportProgress|exportCurrentFile|exportFilesCompleted|exportScannedEvents|exportMatchedEvents|exportOutputBytes/);
assert.match(html, /aria-label="[^\"]+"/);
}
});