#!/usr/bin/env node /** * ObsiGate — JSDOM tests for the spreadsheet menus (ROADMAP #179, #185). * * Covers frontend/js/xlsx/context-menu.js, command-bar.js and menus.js: * - every grid-menu entry renders its representative Lucide icon + safe label; * - the grid menu closes on Escape / outside click / focus loss / scroll / * resize, and offers arrow-key navigation with autofocus on open; * - trackDismissable() gives Structure / Mise en forme / Export the same * dismissal contract (toggle exempt, custom onClose); * - the ribbon carries its icons (save, tab "+", gridlines); * - #185: the menu bar lists the ten Google Sheets menus, every entry is * actionable or honestly flagged unavailable, labels come from the catalog, * editing entries disappear on a read-only workbook, and the historical * Fichier ids (#xlsx-file-btn / -menu / #xlsx-download-btn / #xlsx-csv-btn) * survive the move from the sheet strip. * * Usage: node tests/frontend/xlsx-menus.test.mjs */ import { strict as assert } from "node:assert"; import { JSDOM } from "jsdom"; import { fileURLToPath, pathToFileURL } from "node:url"; 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.FocusEvent = w.FocusEvent; globalThis.KeyboardEvent = w.KeyboardEvent; globalThis.MouseEvent = w.MouseEvent; globalThis.localStorage = w.localStorage; globalThis.sessionStorage = w.sessionStorage; globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0); // #185 — the menu bar renders localized labels, so the real FR catalog has to // be reachable: serve /static/locales/*.json from disk and nothing else. const { readFileSync } = await import("node:fs"); globalThis.fetch = async (url) => { const href = String(url); if (href.includes("/static/locales/")) { const file = path.join(REPO_ROOT, "frontend", "locales", "fr.json"); return { ok: true, status: 200, json: async () => JSON.parse(readFileSync(file, "utf8")) }; } return { ok: true, status: 200, json: async () => ({}), text: async () => "" }; }; // ── Module under test ─────────────────────────────────────────────────────── const ctxMenuUrl = pathToFileURL( path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js") ).href; const cmdBarUrl = pathToFileURL( path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js") ).href; const menusUrl = pathToFileURL( path.join(REPO_ROOT, "frontend", "js", "xlsx", "menus.js") ).href; const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } = await import(ctxMenuUrl); const { buildCommandBar } = await import(cmdBarUrl); const { buildMenuBar, SHEETS_MENUS } = await import(menusUrl); const { initI18n } = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href ); await initI18n(); // ── Mini runner ───────────────────────────────────────────────────────────── let testCount = 0; let passCount = 0; async function test(name, fn) { testCount++; closeContextMenu(); document.body.querySelectorAll(".tmp-menu").forEach((m) => m.remove()); try { await fn(); passCount++; console.log(` ✓ ${name}`); } catch (e) { console.error(` ✗ ${name}`); console.error(e); process.exitCode = 1; } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const itemsOf = () => [ { icon: "scissors", label: "Cut" }, { icon: "copy", label: "Copy", disabled: true }, { separator: true }, { icon: "trash-2", label: "Delete", danger: true, onClick: () => {} }, ]; // ── Rendering ─────────────────────────────────────────────────────────────── await test("entries render their icon, label, separator and danger style", () => { const menu = openContextMenu(10, 10, itemsOf()); const btns = [...menu.querySelectorAll(".xlsx-context-item")]; assert.equal(btns.length, 3); assert.equal(btns[0].querySelector("i").getAttribute("data-lucide"), "scissors"); assert.equal(btns[0].querySelector(".xlsx-menu-label").textContent, "Cut"); assert.equal(btns[1].disabled, true); assert.ok(btns[2].classList.contains("xlsx-context-danger")); assert.ok(menu.querySelector(".xlsx-context-sep"), "separator rendered"); assert.equal(menu.getAttribute("role"), "menu"); }); await test("labels are text, never HTML (XSS-safe)", () => { const menu = openContextMenu(10, 10, [{ icon: "copy", label: "" }]); assert.equal(menu.querySelector("img"), null); assert.equal( menu.querySelector(".xlsx-menu-label").textContent, "" ); }); await test("opening focuses the first enabled entry", () => { const menu = openContextMenu(10, 10, itemsOf()); const first = menu.querySelectorAll(".xlsx-context-item")[0]; assert.equal(document.activeElement, first); }); // ── Keyboard navigation ───────────────────────────────────────────────────── await test("ArrowDown / ArrowUp / Home / End walk through enabled entries", () => { const menu = openContextMenu(10, 10, itemsOf()); const btns = [...menu.querySelectorAll(".xlsx-context-item")]; const key = (k) => menu.dispatchEvent(new w.KeyboardEvent("keydown", { key: k, bubbles: true })); key("ArrowDown"); // Cut (0, disabled Copy skipped) -> Delete (2) assert.equal(document.activeElement, btns[2]); key("ArrowDown"); // wraps to Cut assert.equal(document.activeElement, btns[0]); key("ArrowUp"); // back to Delete assert.equal(document.activeElement, btns[2]); key("Home"); assert.equal(document.activeElement, btns[0]); key("End"); assert.equal(document.activeElement, btns[2]); }); // ── Dismissal ─────────────────────────────────────────────────────────────── await test("Escape closes the menu", () => { openContextMenu(10, 10, itemsOf()); assert.ok(isContextMenuOpen()); document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); assert.ok(!isContextMenuOpen()); assert.equal(document.querySelector(".xlsx-context-menu"), null); }); await test("outside click closes, inside click does not", async () => { const menu = openContextMenu(10, 10, itemsOf()); await sleep(300); // past the long-press click guard menu.querySelector(".xlsx-context-item").dispatchEvent( new w.MouseEvent("click", { bubbles: true }) ); assert.ok(!isContextMenuOpen(), "item click closes"); openContextMenu(10, 10, itemsOf()); await sleep(300); document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true })); assert.ok(!isContextMenuOpen(), "outside click closes"); }); await test("focus leaving the menu closes it, focus inside keeps it", () => { const menu = openContextMenu(10, 10, itemsOf()); const [first, , last] = menu.querySelectorAll(".xlsx-context-item"); first.dispatchEvent( new w.FocusEvent("focusout", { bubbles: true, relatedTarget: last }) ); assert.ok(isContextMenuOpen(), "focus moving inside keeps the menu"); const outside = document.createElement("button"); document.body.appendChild(outside); last.dispatchEvent( new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside }) ); assert.ok(!isContextMenuOpen(), "focus moving outside closes the menu"); outside.remove(); }); await test("double close never throws and a late click still runs its action", () => { // Regression: in a real browser, remove() fires focusout synchronously, // which re-entered closeContextMenu — the second remove() threw // NotFoundError and aborted the item's onClick before it ran. let runs = 0; const menu = openContextMenu(10, 10, [{ icon: "copy", label: "Copy", onClick: () => { runs++; } }]); const btn = menu.querySelector(".xlsx-context-item"); closeContextMenu(); closeContextMenu(); // idempotent: no-op, no throw assert.ok(!isContextMenuOpen()); btn.dispatchEvent(new w.MouseEvent("click", { bubbles: true })); assert.equal(runs, 1, "click on the detached entry still runs its action"); }); await test("scroll and resize close the menu", async () => { openContextMenu(10, 10, itemsOf()); window.dispatchEvent(new w.Event("resize")); assert.ok(!isContextMenuOpen(), "resize closes"); openContextMenu(10, 10, itemsOf()); // The smooth-scroll tail of the opening gesture is forgiven… window.dispatchEvent(new w.Event("scroll")); assert.ok(isContextMenuOpen(), "immediate scroll does not close"); // …but a later scroll detaches the menu. await sleep(450); window.dispatchEvent(new w.Event("scroll")); assert.ok(!isContextMenuOpen(), "later scroll closes"); }); // ── trackDismissable (Structure / Format / Export) ────────────────────────── function tmpMenu(toggle) { const menu = document.createElement("div"); menu.className = "tmp-menu"; const b = document.createElement("button"); b.type = "button"; b.textContent = "Action"; menu.appendChild(b); (toggle.closest("div") || document.body).appendChild(menu); return menu; } await test("trackDismissable: toggle exempt, outside closes with onClose", async () => { const host = document.createElement("div"); const toggle = document.createElement("button"); host.appendChild(toggle); document.body.appendChild(host); const menu = tmpMenu(toggle); let closed = 0; const dismiss = trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } }); toggle.dispatchEvent(new w.MouseEvent("click", { bubbles: true })); assert.equal(closed, 0, "toggle click does not dismiss"); assert.ok(menu.isConnected); document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true })); assert.equal(closed, 1, "outside click dismisses once"); assert.ok(!menu.isConnected); dismiss(); // idempotent manual close assert.equal(closed, 1); host.remove(); }); await test("trackDismissable: Escape and focus loss dismiss", () => { const toggle = document.createElement("button"); document.body.appendChild(toggle); const menu = tmpMenu(toggle); let closed = 0; trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } }); document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); assert.equal(closed, 1, "Escape dismisses"); assert.ok(!menu.isConnected); const menu2 = tmpMenu(toggle); let closed2 = 0; trackDismissable(menu2, { toggle, onClose: () => { closed2++; menu2.remove(); } }); const outside = document.createElement("input"); document.body.appendChild(outside); menu2.querySelector("button").dispatchEvent( new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside }) ); assert.equal(closed2, 1, "focus loss dismisses"); outside.remove(); toggle.remove(); }); // ── Ribbon icons (#185: the File menu moved to the menu bar) ──────────────── await test("the ribbon carries save, tab-plus and gridline icons", () => { const { actionsHtml, tabs, fileHtml } = buildCommandBar({ sheets: [{ name: "F1" }], isCsv: false, readOnly: false, editable: true, }); const host = document.createElement("div"); host.innerHTML = tabs + actionsHtml + fileHtml; const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]"); assert.ok(saveIcon, "save button has an icon"); assert.equal(saveIcon.getAttribute("data-lucide"), "save"); const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]"); assert.ok(plusIcon, "tab + button has an icon"); assert.equal(plusIcon.getAttribute("data-lucide"), "plus"); const gridIcon = host.querySelector("#xs-grid i[data-lucide]"); assert.ok(gridIcon, "gridline switch has an icon"); assert.equal(gridIcon.getAttribute("data-lucide"), "grid-3x3"); // #185 — the sheet strip keeps Save only; the file outputs live in the menu bar. assert.equal(host.querySelector("#xlsx-file-btn"), null, "no file dropdown left on the strip"); }); // ── #185 — the menu bar itself ────────────────────────────────────────────── await test("buildMenuBar renders the ten Sheets menus with their labels", () => { assert.deepEqual( SHEETS_MENUS.map((m) => m.id), ["file", "edit", "view", "insert", "format", "data", "tools", "gemini", "extensions", "help"], ); const html = buildMenuBar({ editable: true, readOnly: false }); const host = document.createElement("div"); host.innerHTML = html; const menus = [...host.querySelectorAll(".xlsx-menubar > .xlsx-menuwrap")]; assert.deepEqual( menus.map((w) => w.querySelector(".xlsx-menubtn").textContent.trim()), ["Fichier", "Modifier", "Affichage", "Insertion", "Format", "Données", "Outils", "Gemini", "Extensions", "Aide"], ); // Every button controls its own dropdown, hidden until opened. menus.forEach((w) => { const btn = w.querySelector(".xlsx-menubtn"); const drop = host.querySelector(`#${btn.getAttribute("aria-controls")}`); assert.ok(drop, `${btn.dataset.xlsxMenu} has a dropdown`); assert.equal(drop.getAttribute("role"), "menu"); assert.equal(drop.style.display, "none", "dropdown starts closed"); assert.equal(btn.getAttribute("aria-expanded"), "false"); }); }); await test("the Fichier menu keeps the historical ids and export markers", () => { const host = document.createElement("div"); host.innerHTML = buildMenuBar({ editable: true, readOnly: false }); assert.ok(host.querySelector("#xlsx-file-btn"), "file button id kept"); assert.ok(host.querySelector("#xlsx-file-menu"), "file menu id kept"); assert.ok(host.querySelector("#xlsx-download-btn"), "download entry kept its id"); assert.ok(host.querySelector("#xlsx-csv-btn"), "csv entry kept its id"); assert.deepEqual( [...host.querySelectorAll("#xlsx-file-menu [data-xlsx-export]")].map((n) => n.dataset.xlsxExport), ["md", "html", "print"], ); }); await test("every menu entry is either actionable or honestly flagged", () => { const host = document.createElement("div"); host.innerHTML = buildMenuBar({ editable: true, readOnly: false }); const rows = [...host.querySelectorAll(".xlsx-menu-item")]; assert.ok(rows.length > 120, `all Sheets entries are listed (${rows.length})`); rows.forEach((row) => { const actionable = row.hasAttribute("data-xlsx-action") || row.hasAttribute("data-xlsx-export"); const flagged = row.hasAttribute("data-xlsx-unavailable"); assert.ok(actionable || flagged || row.hasAttribute("aria-haspopup"), `"${row.textContent.trim()}" is actionable, a section, or flagged unavailable`); }); // A declared action always resolves against the viewer's registry (#185). const ACTIONS = [ "share", "download", "export-csv", "rename", "trash", "history", "details", "settings", "print", "undo", "redo", "cut", "copy", "paste", "paste-values", "delete-rows", "delete-cols", "clear-contents", "select-all", "find-replace", "freeze", "freeze-row", "freeze-col", "unfreeze", "toggle-formula-bar", "toggle-grid", "toggle-headers", "dashboard", "zoom", "fullscreen", "insert-rows-above", "insert-rows-below", "insert-cols-left", "insert-cols-right", "insert-cells", "link", "comment", "function", "numfmt", "theme-menu", "bold", "italic", "underline", "strike", "fontsize", "align", "valign", "wrap-on", "wrap-off", "rotate", "clear-format", "banded", "sort-asc", "sort-desc", "sort-reset", "filter-column", "named-ranges", "cleanup-dupes", "cleanup-trim", "split-text", "autocomplete", "a11y", "gemini", "help", "whatsnew", "shortcuts", "report", ]; const declared = new Set( [...host.querySelectorAll("[data-xlsx-action]")].map((n) => n.dataset.xlsxAction), ); ACTIONS.forEach((name) => assert.ok(declared.has(name), `menu declares "${name}"`)); // "apply-theme" is deliberately absent: the theme list is built on demand. assert.equal(declared.has("apply-theme"), false, "themes are not hard-coded in the markup"); }); await test("Format ▸ Thème is an empty section the viewer fills on demand (#185)", () => { const host = document.createElement("div"); host.innerHTML = buildMenuBar({ editable: true, readOnly: false }); const row = host.querySelector('[data-xlsx-menu="format"]') .closest(".xlsx-menuwrap") .querySelector('[data-xlsx-action="theme-menu"]'); assert.ok(row, "the Thème section exists"); const sub = row.nextElementSibling; assert.equal(sub.className, "xlsx-menu-sub", "it is a section, not a leaf"); assert.equal(sub.hidden, true, "collapsed"); assert.equal(sub.children.length, 0, "empty until the viewer fills it"); assert.equal(row.getAttribute("aria-expanded"), "false"); // The next entry is untouched by the empty section. assert.ok(host.querySelector('[data-xlsx-action="bold"]'), "Bold still listed"); }); await test("editing entries disappear on a read-only workbook, view entries stay", () => { const host = document.createElement("div"); host.innerHTML = buildMenuBar({ editable: false, readOnly: true }); assert.equal(host.querySelector('[data-xlsx-action="bold"]'), null, "no Bold"); assert.equal(host.querySelector('[data-xlsx-action="insert-cells"]'), null, "no Insert cells"); assert.equal(host.querySelector('[data-xlsx-action="undo"]'), null, "no Undo"); assert.ok(host.querySelector('[data-xlsx-action="copy"]'), "Copy stays available"); assert.ok(host.querySelector('[data-xlsx-action="zoom"]'), "Zoom stays available"); assert.ok(host.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay available"); }); await test("menu labels come from the i18n catalog, never from raw text", () => { const host = document.createElement("div"); host.innerHTML = buildMenuBar({ editable: true, readOnly: false }); const labels = [...host.querySelectorAll(".xlsx-menu-label")].map((n) => n.textContent); assert.ok(labels.length > 100); assert.equal(labels.some((l) => /xlsx\./.test(l)), false, "no unresolved key is rendered"); assert.ok(labels.includes("Nouveau"), "Google Sheets' own wording is kept"); assert.ok(labels.includes("Couleurs alternées")); assert.ok(labels.includes("Diviser le texte en colonnes")); }); await test("a sheet name or cell value cannot leak HTML into a menu label", () => { const host = document.createElement("div"); host.innerHTML = buildMenuBar({ editable: true, readOnly: false }); host.querySelectorAll(".xlsx-menu-label").forEach((n) => { assert.equal(n.querySelector("img, script"), null, "labels stay text nodes"); }); }); console.log(`\n${passCount}/${testCount} passed`); process.exit(process.exitCode || 0);