275 lines
16 KiB
JavaScript
275 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ObsiGate — Configurations modal mobile usability tests (BUG-071 + #114).
|
|
*
|
|
* Static checks (no jsdom needed — runs in the "Frontend unit tests" CI step):
|
|
* - BUG-071a: #config-nav shared the .help-nav rule that hides it below
|
|
* 768px, but the config modal had no toggle (the help modal has
|
|
* #help-hamburger) → the table of contents was unreachable on mobile.
|
|
* The header must carry #config-hamburger (same .help-hamburger
|
|
* treatment: hidden on desktop, visible on mobile) wired in config.js.
|
|
* - BUG-071b: the TOC links were bare anchors with no JS — no active state,
|
|
* no auto-collapse on mobile, unreliable scrolling inside the modal.
|
|
* config.js must smooth-scroll to the section, mark it active and collapse
|
|
* the nav on mobile, and reset the nav on open.
|
|
* - BUG-071c: two-column grids (.ai-default-grid, .ai-provider-fields),
|
|
* fixed-width add-rows (.config-add-row, 180/140/100px inline widths) and
|
|
* single-line webhook/token/share items overflowed a 360px viewport.
|
|
* style.css must stack/wrap them below 768px with 44px touch targets.
|
|
* - BUG-071d: every #config-nav link target must exist (dead-anchor guard,
|
|
* same class of bug as BUG-067 for the help modal).
|
|
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"
|
|
* separated) so the toggle carries translated title AND aria-label.
|
|
* - #114a: the TOC is a left slide-over drawer (position: fixed) opened by
|
|
* .config-toc-open on #config-modal, with a backdrop (::before) and a
|
|
* close button (#config-toc-close).
|
|
* - #114b: the modal is full-screen (100dvh), inputs are ≥16px anti-zoom
|
|
* with 44px min-height, buttons are 44px touch targets, the backend Save
|
|
* row is sticky, and the MFA recovery list is single-column on mobile.
|
|
* - #114i18n: dead settings.* keys purged, settings.explorer and
|
|
* config.toc_close present in FR and EN, settings.tabs translated in FR.
|
|
*
|
|
* Usage: node tests/frontend/config-mobile.test.mjs
|
|
*/
|
|
|
|
import { strict as assert } from "node:assert";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
|
|
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
|
|
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
|
|
const i18nJs = readFileSync(path.join(ROOT, "frontend", "js", "i18n.js"), "utf8");
|
|
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
|
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
|
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
|
|
|
function test(label, fn) {
|
|
try {
|
|
fn();
|
|
console.log(" ✓ " + label);
|
|
} catch (err) {
|
|
console.error(" ✗ " + label + "\n " + err.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
// ── BUG-071a: header TOC toggle ─────────────────────────────────────────────
|
|
test("index.html — #config-hamburger exists in the config modal header", () => {
|
|
const modal = indexHtml.match(/<div class="editor-modal" id="config-modal">([\s\S]*?)<div class="editor-body help-body" id="config-body">/);
|
|
assert.ok(modal, "#config-modal with #config-body not found");
|
|
const header = modal[1].match(/<div class="editor-header">([\s\S]*?)<\/div>\s*<\/div>/);
|
|
assert.ok(header, "config modal .editor-header not found");
|
|
assert.match(header[1], /id="config-hamburger"/, "no #config-hamburger in the config header — TOC unreachable on mobile");
|
|
assert.match(header[1], /help-hamburger/, "the toggle must reuse .help-hamburger (desktop-hidden, mobile-visible)");
|
|
assert.match(header[1], /aria-label/, "the toggle needs an accessible label");
|
|
assert.match(header[1], /config\.toc_toggle/, "the toggle label must use the i18n key config.toc_toggle");
|
|
});
|
|
|
|
test("i18n — config.toc_toggle exists in FR and EN", () => {
|
|
assert.ok(fr["config.toc_toggle"], "fr.json missing config.toc_toggle");
|
|
assert.ok(en["config.toc_toggle"], "en.json missing config.toc_toggle");
|
|
assert.notEqual(fr["config.toc_toggle"], "config.toc_toggle", "FR value must be translated");
|
|
assert.notEqual(en["config.toc_toggle"], "config.toc_toggle", "EN value must be translated");
|
|
});
|
|
|
|
// ── BUG-071b: TOC behaviour in config.js ────────────────────────────────────
|
|
test("config.js — hamburger toggles #config-nav", () => {
|
|
assert.match(configJs, /getElementById\("config-hamburger"\)/, "no binding on #config-hamburger");
|
|
assert.match(configJs, /_setConfigNav\(/, "TOC open/close helper missing");
|
|
});
|
|
|
|
test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", () => {
|
|
assert.match(configJs, /#config-nav[\s\S]{0,400}?help-nav-link/, "no handler on the #config-nav links");
|
|
assert.match(configJs, /scrollIntoView/, "section scroll must use scrollIntoView inside the modal");
|
|
assert.match(configJs, /innerWidth <= 768/, "the nav must auto-collapse on mobile viewports only");
|
|
});
|
|
|
|
test("config.js — TOC display reset when the modal opens", () => {
|
|
assert.match(configJs, /configNavOnOpen[\s\S]{0,120}?style\.display = ''/, "stale inline display would stick across sessions");
|
|
assert.match(configJs, /modal\.classList\.remove\("config-toc-open"\)/,
|
|
"the drawer-open class (backdrop) must be cleared on open");
|
|
});
|
|
|
|
// ── BUG-071c: mobile CSS ────────────────────────────────────────────────────
|
|
test("style.css — config TOC becomes a fixed drawer on mobile", () => {
|
|
assert.match(css, /#config-modal #config-nav/, "no mobile rule scoped to #config-modal #config-nav");
|
|
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?position: fixed/,
|
|
"the TOC must be a fixed slide-over drawer on mobile");
|
|
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/,
|
|
"the drawer must be height-capped so content stays reachable");
|
|
});
|
|
|
|
test("style.css — two-column config grids stack on mobile", () => {
|
|
assert.match(css, /#config-modal \.ai-default-grid/, ".ai-default-grid still 2 columns on mobile");
|
|
assert.match(css, /#config-modal \.ai-provider-fields/, ".ai-provider-fields still 3fr/2fr on mobile");
|
|
assert.match(css, /grid-template-columns: 1fr;/, "mobile grids must collapse to a single column");
|
|
});
|
|
|
|
test("style.css — add-rows wrap and fixed inline widths are neutralised", () => {
|
|
assert.match(css, /#config-modal \.config-add-row/, "no mobile rule for .config-add-row (token/webhook rows overflow)");
|
|
assert.match(css, /width: auto !important/, "fixed inline widths (180/140/100px) must be overridden on mobile");
|
|
assert.match(css, /min-height: 44px/, "mobile action controls need 44px touch targets");
|
|
});
|
|
|
|
test("style.css — webhook/token/share rows wrap on mobile", () => {
|
|
for (const cls of ["webhook-item", "token-item", "share-item"]) {
|
|
assert.match(css, new RegExp("#config-modal \\." + cls), `.${cls} has no mobile wrap rule`);
|
|
}
|
|
});
|
|
|
|
// ── BUG-071d: dead-anchor guard ─────────────────────────────────────────────
|
|
test("index.html — every #config-nav link resolves to an element id", () => {
|
|
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
|
|
assert.ok(nav, "#config-nav not found");
|
|
const hrefs = [...nav[1].matchAll(/href="(#[^"]+)"/g)].map((m) => m[1].slice(1));
|
|
assert.ok(hrefs.length > 0, "no links in #config-nav");
|
|
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
|
|
assert.deepEqual(missing, [], `dead TOC anchors (cf. BUG-067): ${missing.join(", ")}`);
|
|
});
|
|
|
|
// ── BUG-071e: multi-pair data-i18n-attr ─────────────────────────────────────
|
|
test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
|
|
assert.match(i18nJs, /split\(['"];/, "pairs must be split on ';'");
|
|
assert.match(i18nJs, /el\.setAttribute\(attr, t\(key\)\)/, "each pair must set its attribute");
|
|
});
|
|
|
|
// ── #114a: drawer, backdrop, close button ───────────────────────────────────
|
|
test("index.html — #config-toc-close exists inside the TOC header", () => {
|
|
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
|
|
assert.ok(nav, "#config-nav not found");
|
|
assert.match(nav[1], /id="config-toc-close"/, "no #config-toc-close in the TOC — no way to dismiss the drawer");
|
|
assert.match(nav[1], /config\.toc_close/, "the close button label must use the i18n key config.toc_close");
|
|
});
|
|
|
|
test("i18n — config.toc_close exists in FR and EN", () => {
|
|
assert.ok(fr["config.toc_close"], "fr.json missing config.toc_close");
|
|
assert.ok(en["config.toc_close"], "en.json missing config.toc_close");
|
|
assert.notEqual(fr["config.toc_close"], "config.toc_close", "FR value must be translated");
|
|
assert.notEqual(en["config.toc_close"], "config.toc_close", "EN value must be translated");
|
|
});
|
|
|
|
test("config.js — drawer open class + close button + backdrop + Escape", () => {
|
|
assert.match(configJs, /classList\.toggle\("config-toc-open"/,
|
|
"_setConfigNav must toggle .config-toc-open on the modal (backdrop + drawer CSS)");
|
|
assert.match(configJs, /getElementById\("config-toc-close"\)/,
|
|
"no binding on #config-toc-close");
|
|
assert.match(configJs, /config-toc-open[\s\S]{0,200}?_setConfigNav\(false\)/,
|
|
"a backdrop tap must close the drawer first, not the whole modal");
|
|
assert.match(configJs, /Escape[\s\S]{0,200}?config-toc-open[\s\S]{0,120}?_setConfigNav\(false\)/,
|
|
"Escape must close the drawer first, then the modal");
|
|
assert.match(configJs, /closeConfigModal[\s\S]{0,200}?classList\.remove\("config-toc-open"\)/,
|
|
"closeConfigModal must never leave the backdrop class behind");
|
|
});
|
|
|
|
test("style.css — drawer backdrop via .config-toc-open::before", () => {
|
|
assert.match(css, /#config-modal\.config-toc-open::before/,
|
|
"no backdrop pseudo-element for the open drawer");
|
|
assert.match(css, /#config-modal\.config-toc-open::before[\s\S]{0,200}?z-index: 35/,
|
|
"the backdrop must sit above the content and below the drawer (z-index 40)");
|
|
assert.match(css, /#config-modal\.config-toc-open #config-nav/,
|
|
"the open drawer must be forced visible via the modal class");
|
|
});
|
|
|
|
test("style.css — .help-toc-close hidden on desktop, visible in the config drawer", () => {
|
|
assert.match(css, /\.help-toc-close\s*\{[^}]*display:\s*none/,
|
|
".help-toc-close must be hidden by default (desktop nav is always visible)");
|
|
assert.match(css, /#config-modal \.help-toc-close\s*\{[^}]*display:\s*inline-flex/,
|
|
"#config-modal .help-toc-close must show on mobile");
|
|
});
|
|
|
|
// ── #114b: full-screen, anti-zoom, touch targets, sticky save ───────────────
|
|
test("style.css — config modal goes full-screen with 100dvh on mobile", () => {
|
|
assert.match(css, /#config-modal \.editor-container[\s\S]{0,300}?100dvh/,
|
|
"the modal must fill the dynamic viewport (mobile browser chrome)");
|
|
assert.match(css, /#config-modal[\s\S]{0,200}?padding:\s*0/,
|
|
"the modal must drop its outer padding on mobile");
|
|
});
|
|
|
|
test("style.css — inputs are ≥16px with 44px min-height (anti-zoom iOS)", () => {
|
|
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?font-size:\s*16px/,
|
|
"config inputs must be ≥16px to stop iOS auto-zoom");
|
|
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?min-height:\s*44px/,
|
|
"config inputs need a 44px touch target");
|
|
assert.match(css, /#config-modal \.config-select[\s\S]{0,200}?font-size:\s*16px/,
|
|
"config selects must be ≥16px to stop iOS auto-zoom");
|
|
});
|
|
|
|
test("style.css — primary buttons are 44px touch targets", () => {
|
|
// The shared button block lists many selectors; match from the block start
|
|
// (first #config-modal .config-btn-save) to the min-height declaration.
|
|
const buttonBlock = css.match(/#config-modal \.config-btn-save,[\s\S]{0,600}?\{[^}]*min-height:\s*44px/);
|
|
assert.ok(buttonBlock, ".config-btn-save needs a 44px touch target");
|
|
for (const cls of ["config-btn-secondary", "config-btn-danger", "config-btn-sm"]) {
|
|
assert.match(css, new RegExp(`#config-modal \\.${cls}`), `#${cls} has no #114 mobile rule`);
|
|
}
|
|
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,400}?min-height:\s*44px/,
|
|
"buttons inside the sticky Save row need a 44px touch target");
|
|
});
|
|
|
|
test("style.css — backend Save row is sticky at the bottom of its section", () => {
|
|
assert.match(css, /#config-modal \.config-actions-row\s*\{[^}]*position:\s*sticky/,
|
|
"the Save row must stick to the bottom while scrolling the backend section");
|
|
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?flex-direction:\s*column/,
|
|
"the Save row must stack vertically on mobile");
|
|
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?env\(safe-area-inset-bottom/,
|
|
"the sticky Save row must clear the home indicator");
|
|
});
|
|
|
|
test("style.css — MFA recovery list single-column + verify section wraps", () => {
|
|
assert.match(css, /#config-modal \.mfa-recovery-list\s*\{[^}]*grid-template-columns:\s*1fr/,
|
|
"recovery codes must be a single column on mobile");
|
|
assert.match(css, /#config-modal \.mfa-verify-section[\s\S]{0,200}?flex-wrap:\s*wrap/,
|
|
"the MFA verify row must wrap instead of overflowing");
|
|
assert.match(css, /#config-modal \.mfa-code-input[\s\S]{0,300}?width:\s*100%/,
|
|
"the MFA code input must be full-width on mobile");
|
|
});
|
|
|
|
test("index.html — .config-actions-row lives inside #cfg-backend-settings", () => {
|
|
const section = indexHtml.match(/<section[^>]*id="cfg-backend-settings"[\s\S]*?<\/section>/);
|
|
assert.ok(section, "#cfg-backend-settings section not found");
|
|
assert.match(section[0], /class="config-actions-row"/,
|
|
".config-actions-row must be inside the backend section (sticky footer contract)");
|
|
});
|
|
|
|
// ── #114i18n: dead keys purged, new keys added ──────────────────────────────
|
|
test("i18n — dead settings.* keys are purged from FR and EN", () => {
|
|
const dead = ["settings.backend", "settings.backend_hint", "settings.restart_badge",
|
|
"settings.save", "settings.plugins"];
|
|
for (const key of dead) {
|
|
assert.equal(fr[key], undefined, `fr.json still has dead key ${key}`);
|
|
assert.equal(en[key], undefined, `en.json still has dead key ${key}`);
|
|
}
|
|
});
|
|
|
|
test("i18n — settings.explorer exists in FR and EN", () => {
|
|
assert.ok(fr["settings.explorer"], "fr.json missing settings.explorer");
|
|
assert.ok(en["settings.explorer"], "en.json missing settings.explorer");
|
|
assert.notEqual(fr["settings.explorer"], "settings.explorer", "FR value must be translated");
|
|
assert.notEqual(en["settings.explorer"], "settings.explorer", "EN value must be translated");
|
|
});
|
|
|
|
test("i18n — settings.tabs is translated in FR (was the English word \"Tabs\")", () => {
|
|
assert.equal(fr["settings.tabs"], "Onglets", "fr.json settings.tabs must be French");
|
|
assert.equal(en["settings.tabs"], "Tabs", "en.json settings.tabs must stay English");
|
|
});
|
|
|
|
test("index.html — #mt-explorer label carries data-i18n=settings.explorer", () => {
|
|
assert.match(indexHtml, /id="mt-explorer"[\s\S]{0,400}?data-i18n="settings\.explorer"/,
|
|
"the explorer toolbar button must be translated via settings.explorer");
|
|
});
|
|
|
|
test("index.html — no dead container #plugins-settings-container", () => {
|
|
assert.ok(!indexHtml.includes("plugins-settings-container"),
|
|
"#plugins-settings-container was a dead div — plugins render into #cfg-plugins");
|
|
});
|
|
|
|
if (process.exitCode) {
|
|
console.error("\nConfig mobile tests FAILED");
|
|
} else {
|
|
console.log("\nAll config mobile tests passed.");
|
|
}
|