feat: refonte mobile de la section Configurations #114
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* E2E tests for the Configurations modal on mobile (BUG-071).
|
||||
* E2E tests for the Configurations modal on mobile (BUG-071 + #114).
|
||||
*
|
||||
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
|
||||
* skipped on the desktop project that the CI job executes — same convention
|
||||
* as mobile-editor.spec.js.
|
||||
*
|
||||
* Covered:
|
||||
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav,
|
||||
* which is hidden by default on mobile;
|
||||
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav
|
||||
* as a left slide-over drawer (fixed positioning), hidden by default;
|
||||
* - the drawer has a close button (#config-toc-close) and a dimming backdrop
|
||||
* (tap outside closes the drawer, not the whole modal);
|
||||
* - picking a TOC entry scrolls to the section, marks the link active and
|
||||
* collapses the nav;
|
||||
* collapses the drawer;
|
||||
* - the modal is full-screen (100dvh) and interactive controls are ≥44px;
|
||||
* - the modal content does not overflow horizontally at 393px.
|
||||
*
|
||||
* Run:
|
||||
@@ -20,6 +23,7 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const MOBILE_MAX_WIDTH = 768;
|
||||
const MIN_TOUCH_TARGET = 44;
|
||||
|
||||
async function boot(page) {
|
||||
await page.goto('/');
|
||||
@@ -33,8 +37,8 @@ async function openConfigModal(page) {
|
||||
await expect(page.locator('#config-modal.active')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Configurations modal on mobile (BUG-071)', () => {
|
||||
test('hamburger reveals the table of contents', async ({ page, viewport }) => {
|
||||
test.describe('Configurations modal on mobile (BUG-071 + #114)', () => {
|
||||
test('hamburger reveals the TOC as a fixed drawer', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
await openConfigModal(page);
|
||||
@@ -45,9 +49,39 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
|
||||
|
||||
await page.locator('#config-hamburger').click();
|
||||
await expect(page.locator('#config-nav')).toBeVisible();
|
||||
|
||||
// #114: the drawer is a fixed slide-over, not an inline top block.
|
||||
const position = await page.locator('#config-nav').evaluate(
|
||||
(el) => window.getComputedStyle(el).position,
|
||||
);
|
||||
expect(position).toBe('fixed');
|
||||
|
||||
// The modal carries the drawer-open class (backdrop contract).
|
||||
await expect(page.locator('#config-modal')).toHaveClass(/config-toc-open/);
|
||||
});
|
||||
|
||||
test('picking a section scrolls to it and collapses the nav', async ({ page, viewport }) => {
|
||||
test('close button and backdrop dismiss the drawer, not the modal', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
await openConfigModal(page);
|
||||
|
||||
// Close button inside the drawer header.
|
||||
await page.locator('#config-hamburger').click();
|
||||
await expect(page.locator('#config-toc-close')).toBeVisible();
|
||||
await page.locator('#config-toc-close').click();
|
||||
await expect(page.locator('#config-nav')).toBeHidden();
|
||||
await expect(page.locator('#config-modal.active')).toBeVisible();
|
||||
|
||||
// Backdrop tap closes the drawer first; the modal stays open.
|
||||
// Click near the right edge of the modal (outside the left drawer).
|
||||
await page.locator('#config-hamburger').click();
|
||||
await expect(page.locator('#config-nav')).toBeVisible();
|
||||
await page.locator('#config-modal').click({ position: { x: (viewport?.width ?? 393) - 8, y: 200 } });
|
||||
await expect(page.locator('#config-nav')).toBeHidden();
|
||||
await expect(page.locator('#config-modal.active')).toBeVisible();
|
||||
});
|
||||
|
||||
test('picking a section scrolls to it and collapses the drawer', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
await openConfigModal(page);
|
||||
@@ -57,7 +91,7 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
|
||||
await expect(link).toBeVisible();
|
||||
await link.click();
|
||||
|
||||
// Nav collapses on mobile after selection…
|
||||
// Drawer collapses on mobile after selection…
|
||||
await expect(page.locator('#config-nav')).toBeHidden();
|
||||
// …the link is marked active…
|
||||
await expect(link).toHaveClass(/active/);
|
||||
@@ -76,6 +110,37 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
|
||||
.toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('modal is full-screen and key controls meet the 44px touch target', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
await openConfigModal(page);
|
||||
|
||||
// Full-screen: the modal container fills the viewport.
|
||||
const container = page.locator('#config-modal .editor-container');
|
||||
const box = await container.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box.width).toBeGreaterThanOrEqual((viewport?.width ?? 0) - 2);
|
||||
expect(box.height).toBeGreaterThanOrEqual((viewport?.height ?? 0) - 2);
|
||||
|
||||
// Header controls (hamburger + close) are ≥44px.
|
||||
for (const sel of ['#config-hamburger', '#config-close']) {
|
||||
const b = await page.locator(sel).boundingBox();
|
||||
expect(b, `${sel} has no bounding box`).not.toBeNull();
|
||||
expect(b.height, `${sel} height`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
|
||||
expect(b.width, `${sel} width`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
|
||||
}
|
||||
|
||||
// Backend Save row: buttons are ≥44px tall.
|
||||
await page.locator('#config-hamburger').click();
|
||||
await page.locator('#config-nav a[href="#cfg-backend-settings"]').click();
|
||||
await expect(page.locator('#config-nav')).toBeHidden();
|
||||
const saveBtn = page.locator('#cfg-save-backend');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
const saveBox = await saveBtn.boundingBox();
|
||||
expect(saveBox).not.toBeNull();
|
||||
expect(saveBox.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
|
||||
});
|
||||
|
||||
test('no horizontal overflow at 393px', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Configurations modal mobile usability non-regression tests (BUG-071).
|
||||
* 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
|
||||
@@ -18,8 +18,16 @@
|
||||
* 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 (";"-
|
||||
* - 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
|
||||
*/
|
||||
@@ -82,12 +90,17 @@ test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", (
|
||||
|
||||
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 capped top block on mobile", () => {
|
||||
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}?max-height/, "the opened TOC must be height-capped so content stays reachable");
|
||||
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", () => {
|
||||
@@ -124,6 +137,136 @@ test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user