#!/usr/bin/env node /** * ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179). * * Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.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 the new icons (save, tab "+", export entries). * * 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); // ── 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 { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } = await import(ctxMenuUrl); const { buildCommandBar } = await import(cmdBarUrl); // ── 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("scroll and resize close the menu", () => { openContextMenu(10, 10, itemsOf()); window.dispatchEvent(new w.Event("scroll")); assert.ok(!isContextMenuOpen(), "scroll closes"); openContextMenu(10, 10, itemsOf()); window.dispatchEvent(new w.Event("resize")); assert.ok(!isContextMenuOpen(), "resize 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 ──────────────────────────────────────────────────────────── await test("the ribbon carries save, tab-plus and export icons", () => { const { actionsHtml, tabs } = buildCommandBar({ sheets: [{ name: "F1" }], isCsv: false, readOnly: false, editable: true, }); const host = document.createElement("div"); host.innerHTML = tabs + actionsHtml; 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 exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) => i.getAttribute("data-lucide") ); assert.deepEqual(exportIcons, ["file-text", "code", "printer"]); }); console.log(`\n${passCount}/${testCount} passed`); process.exit(process.exitCode || 0);