118 lines
5.5 KiB
JavaScript
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, /<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="[^\"]+"/);
|
|
}
|
|
});
|