fix: degagement mobile sous la barre de navigation du bas (clearance retargetee .main-body) BUG-073
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 1m21s
CI / e2e (push) Successful in 14m0s

This commit is contained in:
2026-09-23 23:36:35 -04:00
parent 33fe1a3439
commit d142049216
14 changed files with 278 additions and 18 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* E2E tests for the mobile bottom toolbar clearance (BUG-073).
*
* The fixed bottom bar (#mobile-toolbar, 64px + safe-area) used to cover the
* last pixels of every document/page: the clearance rule targeted the dead
* `.main-layout` selector instead of `.main-body`, so #content-area reached
* behind the bar and the end of the content was unreachable.
*
* 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 / config-mobile.spec.js.
*
* Run:
* npx playwright test tests/e2e/mobile-toolbar.spec.js --project=chromium-mobile
* pwsh scripts/run-e2e-local.ps1 --project=chromium-mobile -g "BUG-073"
* (ObsiGate listening on http://localhost:2029, auth disabled)
*/
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
const LONG_DOC = 'ANALYSE_REVIEW.md';
async function boot(page) {
await page.goto('/');
await page
.waitForSelector('#app:not(.hidden)', { timeout: 15000 })
.catch(() => {});
}
/** Wait for the async markdown render, then confirm the area actually scrolls. */
async function openLongDocument(page) {
await page.evaluate((p) => window.TabManager.open('TestVault', p), LONG_DOC);
await expect(page.locator('#content-area .md-content')).toBeAttached({ timeout: 15000 });
await expect
.poll(
() =>
page.evaluate(() => {
const area = document.getElementById('content-area');
return area.scrollHeight > area.clientHeight;
}),
{ timeout: 15000 },
)
.toBe(true);
}
/** Geometry of #content-area vs the fixed #mobile-toolbar bar. */
async function measure(page) {
return page.evaluate(() => {
const area = document.getElementById('content-area').getBoundingClientRect();
const bar = document.getElementById('mobile-toolbar').getBoundingClientRect();
return { areaBottom: area.bottom, barTop: bar.top, barVisible: bar.height > 0 };
});
}
test.describe('Mobile bottom toolbar clearance (BUG-073)', () => {
test('content area ends above the fixed bottom toolbar', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
const toolbar = page.locator('#mobile-toolbar');
await expect(toolbar).toBeVisible();
// Open a long document so the layout is in its real document-view state.
await openLongDocument(page);
await expect(page.locator('#content-area')).toBeVisible();
// The scroll container itself must stop at (or above) the bar — 1px
// tolerance for subpixel rounding.
await expect
.poll(async () => {
const m = await measure(page);
return m.areaBottom - m.barTop;
})
.toBeLessThanOrEqual(1);
});
test('the end of a long document scrolls clear of the toolbar', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openLongDocument(page);
// Scroll to the very end: the last rendered element must sit above the bar.
await page.evaluate(() => {
const area = document.getElementById('content-area');
area.scrollTop = area.scrollHeight;
});
const result = await page.evaluate(() => {
const area = document.getElementById('content-area');
const barTop = document.getElementById('mobile-toolbar').getBoundingClientRect().top;
// Last element of the rendered document (markdown root or content child).
const root = area.querySelector('.md-content') || area.lastElementChild;
let last = root;
if (root && root.lastElementChild) last = root.lastElementChild;
const lastBottom = last ? last.getBoundingClientRect().bottom : area.getBoundingClientRect().bottom;
return { lastBottom, barTop };
});
expect(result.lastBottom).toBeLessThanOrEqual(result.barTop + 1);
});
});
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* ObsiGate — Mobile bottom toolbar clearance non-regression tests (BUG-073).
*
* Static checks (no jsdom — runs in the "Frontend unit tests" CI step):
* - BUG-073: the fixed bottom toolbar (#mobile-toolbar, 64px + safe-area)
* covered the last pixels of every document/page because the clearance
* rule in the ≤768px media block targeted `.main-layout` — a class that
* does not exist in index.html (the real layout container is
* `.main-body`). The dead selector reserved no space, so #content-area
* extended behind the bar and the end of the content could never be
* scrolled clear of it.
* - The clearance must live on `.main-body` inside the mobile media block
* (desktop keeps no padding), the dead `.main-layout` rules must be gone,
* reading mode (toolbar hidden) must reset the padding, and the
* now-playing rule must not double-count the toolbar zone.
*
* Usage: node tests/frontend/mobile-toolbar.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 css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + err.message);
process.exitCode = 1;
}
}
/** Body of the first @media (max-width: 768px) block whose body contains `needle`. */
function mobileBlockContaining(needle) {
const marker = "@media (max-width: 768px)";
let idx = 0;
while ((idx = css.indexOf(marker, idx)) !== -1) {
const open = css.indexOf("{", idx);
let depth = 0;
let end = open;
for (; end < css.length; end++) {
if (css[end] === "{") depth++;
else if (css[end] === "}") {
depth--;
if (depth === 0) break;
}
}
const body = css.slice(open + 1, end);
if (body.includes(needle)) return body;
idx = end + 1;
}
return null;
}
// ── The layout container the clearance must target exists in the DOM ───────
test("index.html — layout container .main-body and #mobile-toolbar both exist", () => {
assert.match(indexHtml, /class="main-body"/, "index.html must define the .main-body layout container");
assert.match(indexHtml, /class="mobile-toolbar"/, "index.html must define the .mobile-toolbar bar");
});
// ── BUG-073: clearance inside the mobile media block ────────────────────────
const toolbarBlock = mobileBlockContaining(".mobile-toolbar");
test("style.css — a ≤768px media block styles .mobile-toolbar", () => {
assert.ok(toolbarBlock, "no @media (max-width: 768px) block contains .mobile-toolbar");
});
test("style.css — mobile clearance targets .main-body (64px + safe-area)", () => {
assert.ok(toolbarBlock, "toolbar media block not found");
assert.match(
toolbarBlock,
/\.main-body \{[^}]*padding-bottom:[^}]*calc\(64px \+ env\(safe-area-inset-bottom/,
"the toolbar clearance must pad .main-body inside the mobile block (BUG-073: .main-layout does not exist in index.html)",
);
});
test("style.css — dead .main-layout clearance rules are gone", () => {
assert.ok(toolbarBlock, "toolbar media block not found");
assert.doesNotMatch(
toolbarBlock,
/\.main-layout \{[^}]*padding-bottom/,
".main-layout clearance is a dead selector in index.html (BUG-073)",
);
assert.doesNotMatch(
toolbarBlock,
/\.editor-modal\.active ~ \.main-layout/,
"the sibling clearance reset targeted a selector that never matches (BUG-073)",
);
});
test("style.css — desktop .main-body carries no toolbar padding", () => {
const base = css.match(/\.main-body \{[^}]*\}/);
assert.ok(base, ".main-body base rule not found");
assert.doesNotMatch(base[0], /padding-bottom/, "toolbar clearance must stay mobile-only");
});
// ── Companion rules ─────────────────────────────────────────────────────────
test("style.css — reading mode resets the clearance (toolbar hidden)", () => {
assert.match(
css,
/body\.reading-mode \.main-body \{[^}]*padding-bottom: 0/,
"reading mode hides #mobile-toolbar: .main-body must release the padding",
);
});
test("style.css — np-active keeps only the dock zone (no toolbar double-count)", () => {
assert.match(
css,
/body\.np-active \.content-area \{[^}]*padding-bottom: 76px/,
"toolbar clearance now comes from .main-body: np-active must pad the dock zone only (76px)",
);
assert.doesNotMatch(
css,
/body\.np-active \.content-area \{[^}]*calc\(64px \+ env\(safe-area-inset-bottom[^}]*\+ 76px/,
"the old formula double-counted the toolbar zone already cleared by .main-body",
);
});
if (process.exitCode) {
console.error("\nMobile toolbar tests FAILED");
} else {
console.log("\nAll mobile toolbar tests passed.");
}