#!/usr/bin/env node /** * ObsiGate — JSDOM integration tests for the Excel viewer (ROADMAP #153 P0). * * Loads the real viewer.js module and drives renderXlsxViewer(): * - A1 : `xlsx_lossy_features` renders a warning banner; a save on such a * workbook gets 409 `xlsx_lossy_content`, asks for confirmation and * retries with `force: true` (or gives up when refused); * - A4 : the f(x) toggle flips `allow_formula` in the save payload. * - A8 : a sheet bigger than the render caps shows the truncation notice. * - A9bis : the tail of a truncated sheet is fetched window by window from * GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are * editable like the initial ones. * * Usage: node tests/frontend/xlsx-viewer.test.mjs */ import { strict as assert } from "node:assert"; import { JSDOM } from "jsdom"; import { fileURLToPath, pathToFileURL } from "node:url"; import { readFileSync } from "node:fs"; import path from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, "..", ".."); // ── JSDOM bootstrap ───────────────────────────────────────────────────────── const dom = new JSDOM( `
`, { url: "http://localhost/", pretendToBeVisual: true } ); const w = dom.window; globalThis.window = w; globalThis.document = w.document; globalThis.DOMParser = w.DOMParser; globalThis.HTMLElement = w.HTMLElement; globalThis.Element = w.Element; globalThis.Node = w.Node; globalThis.Event = w.Event; globalThis.CustomEvent = w.CustomEvent; globalThis.MouseEvent = w.MouseEvent; globalThis.localStorage = w.localStorage; globalThis.sessionStorage = w.sessionStorage; globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0); Object.defineProperty(globalThis, "navigator", { value: w.navigator, configurable: true, writable: true, }); // ── fetch / confirm doubles ───────────────────────────────────────────────── let calls = []; let nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } }); let confirmAnswer = true; let confirmCalls = 0; // Every confirm() prompt is captured so the tests can assert on its text. let confirmPrompts = []; const FR = JSON.parse( readFileSync(path.join(REPO_ROOT, "frontend", "locales", "fr.json"), "utf8") ); globalThis.fetch = async (url, opts = {}) => { // The i18n bootstrap fetches the locale files: serve the real FR one so the // assertions run on the shipped strings, not on raw keys. if (url.includes("/static/locales/")) { return { ok: true, status: 200, json: async () => FR }; } calls.push({ url, body: opts.body ? JSON.parse(opts.body) : null }); const res = nextResponse(url, opts); return { ok: res.ok, status: res.status, json: async () => res.body, }; }; globalThis.confirm = (msg) => { confirmCalls++; confirmPrompts.push(msg); return confirmAnswer; }; w.confirm = globalThis.confirm; // ── Helpers ───────────────────────────────────────────────────────────────── let testCount = 0; let passCount = 0; async function test(name, fn) { testCount++; calls = []; confirmCalls = 0; confirmPrompts = []; confirmAnswer = true; apiQueue = []; nextResponse = () => { if (apiQueue.length) return apiQueue.shift(); return { ok: true, status: 200, body: { status: "ok" } }; }; try { await fn(); console.log(` ✓ ${name}`); passCount++; } catch (e) { console.log(` ✗ ${name}`); console.log(` ${e.message}`); if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`); } } const { renderXlsxViewer } = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href ); // Load the FR catalog so t() resolves the real strings. const { initI18n } = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href ); await initI18n(); const sheetHtml = (value) => '
' + '' + `` + "
A
1${value}
"; // The JSDOM fetch double serves locale files; everything else is a recorded // API call answered by `nextResponse`. `apiQueue` lets a test script the // successive windows a lazy-loading sheet will request. let apiQueue = []; function mount({ lossy = [], sheet = {} } = {}) { const area = document.getElementById("content-area"); area.innerHTML = ""; renderXlsxViewer(area, { vault: "V", path: "data.xlsx", is_xlsx: true, xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }], xlsx_lossy_features: lossy, }); return area; } /** Mount a sheet flagged truncated so wireLazyRows() arms the footnote. */ function mountTruncated({ total = 520, rows = 1 } = {}) { return mount({ sheet: { rows, cols: 3, total_rows: total, total_cols: 3, max_rows: 500, max_cols: 40, truncated: total > 500 }, }); } /** Mark a cell dirty the way a user edit would. */ function editCell(area, ref, text) { const td = area.querySelector(`td[data-cell="${ref}"]`); td.textContent = text; td.dispatchEvent(new w.Event("input", { bubbles: true })); return td; } const lossyError = { ok: false, status: 409, body: { detail: "Saving this workbook would drop features…", code: "xlsx_lossy_content", details: { features: ["slicers"] }, }, }; console.log("\n── xlsx viewer JSDOM integration tests (#153 P0) ──\n"); // ── A1 — warning banner ───────────────────────────────────────────────────── await test("no banner when the workbook has nothing at risk", () => { const area = mount(); assert.equal(area.querySelector(".xlsx-warning"), null); }); await test("banner lists every lossy feature reported by the backend", () => { const area = mount({ lossy: ["cached_values", "slicers"] }); const banner = area.querySelector(".xlsx-warning"); assert.ok(banner, "banner absent"); const tags = [...banner.querySelectorAll(".xlsx-warning-tag")].map((n) => n.textContent); assert.equal(tags.length, 2); assert.ok(tags.includes(FR["xlsx.feature_cached_values"]), tags.join("|")); assert.ok(tags.includes(FR["xlsx.feature_slicers"]), tags.join("|")); assert.ok(banner.textContent.includes(FR["xlsx.lossy_title"])); assert.ok(banner.textContent.includes(FR["xlsx.lossy_hint"])); }); await test("formula toggle is present and starts unpressed", () => { const area = mount(); const btn = area.querySelector("#xlsx-formula-btn"); assert.ok(btn); assert.equal(btn.getAttribute("aria-pressed"), "false"); assert.equal(btn.getAttribute("title"), FR["xlsx.formula_toggle_title"]); }); // ── Save payload ──────────────────────────────────────────────────────────── await test("save sends one PUT per dirty sheet with the cell map", async () => { const area = mount(); editCell(area, "A1", "250"); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 0)); assert.equal(calls.length, 1); assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/save\?path=data\.xlsx/); assert.deepEqual(calls[0].body.cells, { A1: "250" }); assert.equal(calls[0].body.sheet, "Feuille1"); assert.equal(calls[0].body.force, false); assert.equal(calls[0].body.allow_formula, false); }); await test("save button stays disabled when nothing is dirty", async () => { const area = mount(); const btn = area.querySelector("#xlsx-save-btn"); assert.equal(btn.disabled, true); btn.click(); await new Promise((r) => setTimeout(r, 0)); assert.equal(calls.length, 0); }); // ── A4 — formula toggle ───────────────────────────────────────────────────── await test("f(x) toggle flips allow_formula on the next save", async () => { const area = mount(); area.querySelector("#xlsx-formula-btn").click(); assert.equal(area.querySelector("#xlsx-formula-btn").getAttribute("aria-pressed"), "true"); editCell(area, "A1", "=B1*2"); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 0)); assert.equal(calls[0].body.allow_formula, true); }); // ── A1 — 409 confirmation & force retry ───────────────────────────────────── await test("409 xlsx_lossy_content asks once then retries with force", async () => { const area = mount({ lossy: ["slicers"] }); editCell(area, "A1", "250"); nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(confirmCalls, 1); assert.equal(calls.length, 2); assert.equal(calls[0].body.force, false); assert.equal(calls[1].body.force, true); // The prompt names the features the backend reported. assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]); // Save succeeded → cells are no longer dirty. assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); }); await test("confirming once is enough for the following saves", async () => { const area = mount({ lossy: ["cached_values"] }); editCell(area, "A1", "1"); nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); editCell(area, "A1", "2"); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(confirmCalls, 1, "the user is not asked twice"); assert.equal(calls.length, 3); assert.equal(calls[2].body.force, true); }); await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => { const area = mount({ lossy: ["slicers"] }); editCell(area, "A1", "250"); nextResponse = () => lossyError; confirmAnswer = false; area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(confirmCalls, 1); assert.equal(calls.length, 1, "no retry after a refusal"); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); assert.equal(area.querySelector("#xlsx-save-btn").disabled, false); }); await test("a non-409 failure is not retried", async () => { const area = mount(); editCell(area, "A1", "250"); nextResponse = () => ({ ok: false, status: 500, body: { detail: "boom" } }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 1); assert.equal(confirmCalls, 0); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); }); // ── A8 — truncation notice ─────────────────────────────────────────────────── await test("no notice when the sheet fits within the render caps", () => { const area = mount({ sheet: { rows: 500, cols: 40, total_rows: 500, total_cols: 40, max_rows: 500, max_cols: 40, truncated: false }, }); assert.equal(area.querySelector(".xlsx-truncated"), null); }); await test("notice states the cap and the real size of a truncated sheet", () => { const area = mount({ sheet: { rows: 500, cols: 12, total_rows: 1200, total_cols: 12, max_rows: 500, max_cols: 40, truncated: true }, }); const note = area.querySelector(".xlsx-truncated"); assert.ok(note, "notice absent"); const txt = note.textContent; assert.ok(txt.includes(FR["xlsx.truncated_title"]), txt); // {shown} is the CAP (500), not the post-trim row count: a sparse sheet // renders 1 row but the view still reaches 500 of them. const expected = FR["xlsx.truncated_rows"].replace("{shown}", "500").replace("{total}", "1200"); assert.ok(txt.includes(expected), `${txt} !includes ${expected}`); // Nothing to say about the columns here (12 < 40). assert.ok(!txt.includes(FR["xlsx.truncated_cols"]), txt); }); await test("notice mentions both axes when rows AND columns overflow", () => { const area = mount({ sheet: { rows: 1, cols: 40, total_rows: 501, total_cols: 45, max_rows: 500, max_cols: 40, truncated: true }, }); const txt = area.querySelector(".xlsx-truncated").textContent; assert.ok( txt.includes(FR["xlsx.truncated_cols"].replace("{shown}", "40").replace("{total}", "45")), txt ); }); await test("a payload without the dimensions shows no notice", () => { // Backward compatibility: an older cached response must not produce "NaN". const area = mount({ sheet: { name: "Feuille1" } }); assert.equal(area.querySelector(".xlsx-truncated"), null); assert.ok(!area.textContent.includes("NaN")); }); // ── A9bis — lazy loading of the truncated tail ────────────────────────────── const windowHtml = (from, to) => '
' + "" + Array.from({ length: to - from + 1 }, (_, i) => { const r = from + i; return ``; }).join("") + "
${r}Ligne ${r}
"; await test("a truncated sheet gets a load-more footnote, a normal one does not", () => { const truncated = mountTruncated(); assert.ok(truncated.querySelector(".xlsx-load-more"), "footnote absent"); assert.ok(truncated.querySelector(".xlsx-load-more").textContent.includes(FR["xlsx.load_more"])); const plain = mount({ sheet: { rows: 10, cols: 2, total_rows: 10, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false } }); assert.equal(plain.querySelector(".xlsx-load-more"), null); }); await test("clicking the footnote fetches the next window with the right query", async () => { const area = mountTruncated(); apiQueue.push({ ok: true, status: 200, body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 2, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 3) }, }); area.querySelector(".xlsx-load-more").click(); await new Promise((r) => setTimeout(r, 5)); assert.equal(calls.length, 1); assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/sheet\?path=data\.xlsx&sheet=Feuille1&offset=1&limit=500/); // Rows 2 and 3 landed in the table with their real coordinates. assert.ok(area.querySelector('td[data-cell="A2"]')); assert.ok(area.querySelector('td[data-cell="A3"]')); assert.ok(area.querySelector('th.xlsx-rownum') && area.textContent.includes("Ligne 3")); // Everything loaded → the footnote is hidden (kept in the DOM, class `done`). const foot = area.querySelector(".xlsx-load-more"); assert.ok(foot, "the footnote element survives"); assert.equal(foot.classList.contains("done"), true); assert.ok(!foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent); }); await test("appended rows are editable and tracked as dirty", async () => { const area = mountTruncated(); apiQueue.push({ ok: true, status: 200, body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) }, }); area.querySelector(".xlsx-load-more").click(); await new Promise((r) => setTimeout(r, 5)); editCell(area, "A2", "modifié"); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 5)); assert.equal(calls.length, 2, "the save went out"); assert.equal(calls[1].url.includes("/xlsx/save"), true); assert.deepEqual(calls[1].body.cells, { A2: "modifié" }); assert.equal(calls[1].body.sheet, "Feuille1"); }); await test("a failed window fetch keeps the footnote and shows an error toast", async () => { const area = mountTruncated(); apiQueue.push({ ok: false, status: 500, body: { detail: "boom" } }); area.querySelector(".xlsx-load-more").click(); await new Promise((r) => setTimeout(r, 5)); const foot = area.querySelector(".xlsx-load-more"); assert.ok(foot, "footnote must survive a failed fetch"); assert.ok(foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent); // `api()` itself toasts the failure (shared behaviour, asserted in E2E); // here we assert the local consequence: the footnote keeps its label. // Retrying works once the server answers again. apiQueue.push({ ok: true, status: 200, body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) }, }); foot.click(); await new Promise((r) => setTimeout(r, 5)); assert.ok(area.querySelector('td[data-cell="A2"]')); assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true); }); await test("two windows in a row walk the whole sheet", async () => { const area = mountTruncated({ total: 1200 }); apiQueue.push({ ok: true, status: 200, body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 500, total_rows: 1200, truncated: true, has_more: true, html: windowHtml(2, 501) }, }); area.querySelector(".xlsx-load-more").click(); await new Promise((r) => setTimeout(r, 5)); assert.ok(area.querySelector('td[data-cell="A501"]')); // The footnote still shows, with the updated progress. let foot = area.querySelector(".xlsx-load-more"); assert.ok(foot, "more rows remain"); assert.ok(foot.textContent.includes("501"), foot.textContent); apiQueue.push({ ok: true, status: 200, body: { sheet: "Feuille1", offset: 501, limit: 500, rows: 200, total_rows: 1200, truncated: true, has_more: false, html: windowHtml(502, 701) }, }); foot.click(); await new Promise((r) => setTimeout(r, 5)); assert.ok(area.querySelector('td[data-cell="A701"]')); assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true); }); // ── Report ────────────────────────────────────────────────────────────────── console.log(`\n${passCount}/${testCount} tests passed\n`); process.exit(passCount === testCount ? 0 : 1);