#!/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);