Files
Codeman/test/mobile-header-buttons-policy.test.ts
T
Codeman maintainer d192eb6825 test(mobile): the gate checks the File Viewer button's phone default again (#809)
Until #809 a display:none rule in mobile.css and its KNOWN_PHONE_HIDDEN
entry kept the File Viewer button off phones, and the gate checked both.
With that rule gone, the handheld default in getDefaultSettings() and the
html.mobile-init startup rule are the whole lock, and only the browser
test saw them, which npm test does not run.

The policy test now loads the real getDefaultSettings() on a handheld and
on a desktop, checks that every reader of the setting goes through the
device default, and that mobile.css keeps the startup hide.

Docs: architecture-invariants, src/web/CLAUDE.md and the wiki say the
button is off on a phone until it is switched on there, and that a window
narrower than 600px on a device that is not a handheld now shows it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 23:07:46 +01:00

270 lines
13 KiB
TypeScript

// Port: none (pure static analysis — runs in CI, no browser/server).
//
// Regression guard for "a header button leaks onto the cramped mobile header".
// This exact class of bug shipped twice (the plan-usage chip, then the COD-39
// attachments-history button): a new control was added to the header, looked
// fine on desktop, and nobody noticed it cluttering the phone header. The mobile
// Playwright suite that *would* catch it (test/mobile/**) is EXCLUDED from CI, so
// it never gated. This test is intentionally a pure parser of index.html +
// mobile.css so it runs in the normal CI sweep with zero browser dependencies.
//
// Policy: every header button that is VISIBLE BY DEFAULT on desktop must have an
// explicit decision for phones — either it's hidden via an @media (max-width:
// 600px) display:none rule in mobile.css, or it's added to MOBILE_VISIBLE_ALLOWLIST
// below with a reason. A new default-visible header button with neither fails this
// test, forcing the author to decide its mobile behavior.
//
// The real-browser counterpart (actual computed visibility on an emulated phone)
// lives in test/mobile/header-buttons.test.ts.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import vm from 'node:vm';
import postcss from 'postcss';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC = join(HERE, '../src/web/public');
// Canonical phone width used to decide whether a media query applies on a phone.
// Matches the device the browser-based test emulates (iPhone 14 Pro = 393px CSS).
const PHONE_WIDTH = 393;
// Issue #759: the maintainer approved File Viewer on phones when explicitly enabled.
// Its per-device setting still defaults off on a handheld. With the blanket phone
// hide gone, that default and the startup hide are the whole lock, so both are
// pinned below ("File Viewer on a phone: off until it is switched on").
const MOBILE_VISIBLE_ALLOWLIST = new Set<string>(['btn-file-viewer']);
// Buttons we expect to STAY hidden on phones — an explicit lock so a future edit
// that removes a hide rule fails loudly (not silently). The attachments button is
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
// it's excluded from the default-visible enumeration rather than mobile-hidden.
// The edit history button is opt-in the same way, but it IS here: its panel is
// desktop-sized, so it stays off phones even when the setting is on.
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-edit-history'];
function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
return m ? m[1] : '';
}
/** Does a media query's width range include a phone-width viewport? */
function appliesToPhone(params: string): boolean {
const max = params.match(/max-width:\s*(\d+)px/);
const min = params.match(/min-width:\s*(\d+)px/);
if (max && PHONE_WIDTH > Number(max[1])) return false; // phone is wider than the cap
if (min && PHONE_WIDTH < Number(min[1])) return false; // phone is narrower than the floor
return Boolean(max || min); // must actually be a width-bounded query
}
function loadHeaderButtons(): { classes: string[]; id: string; distinguishing: string[] }[] {
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
// The header controls live in <div class="header-right" id="headerRight"> … </header>.
const region = html.match(/<div class="header-right"[^>]*>([\s\S]*?)<\/header>/);
expect(region, '#headerRight region not found in index.html — update the selector in this test').toBeTruthy();
const headerHtml = region![1];
return [...headerHtml.matchAll(/<button\b([^>]*)>/g)]
.map((m) => {
const open = m[1];
const classes = attrOf(open, 'class').split(/\s+/).filter(Boolean);
return {
classes,
id: attrOf(open, 'id'),
style: attrOf(open, 'style'),
distinguishing: classes.filter(
(c) => c.startsWith('btn-') && c !== 'btn-icon-header' && c !== 'btn-sm' && !c.endsWith('--hidden')
),
};
})
.filter((b) => b.classes.includes('btn-icon-header'))
.filter((b) => !b.classes.includes('btn-sm')) // font A-/A+ controls — separate sub-group
.filter((b) => !/display:\s*none/i.test(b.style)) // JS-gated (solo-redock, retired bell)
.filter((b) => !b.classes.some((c) => c.endsWith('--hidden'))); // opt-in, hidden by default
}
function loadPhoneHiddenClasses(): Set<string> {
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
const hidden = new Set<string>();
postcss.parse(css).walkAtRules('media', (atRule) => {
if (!appliesToPhone(atRule.params)) return;
atRule.walkRules((rule) => {
let hides = false;
rule.walkDecls('display', (decl) => {
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
});
if (!hides) return;
for (const token of rule.selector.match(/\.btn-[a-z0-9-]+/gi) || []) {
hidden.add(token.slice(1).toLowerCase());
}
});
});
return hidden;
}
describe('Mobile header button policy (static guard)', () => {
const buttons = loadHeaderButtons();
const phoneHidden = loadPhoneHiddenClasses();
it('finds the default-visible header buttons (sanity)', () => {
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
expect(buttons.length).toBeGreaterThan(0);
});
it('every default-visible header button has a mobile-visibility decision', () => {
for (const btn of buttons) {
expect(
btn.distinguishing.length,
`Header button (id=${btn.id || '?'}, class="${btn.classes.join(' ')}") has no distinguishing ` +
`btn-* class to target on mobile. Give it one so its phone visibility can be controlled.`
).toBeGreaterThan(0);
const hidden = btn.distinguishing.some((c) => phoneHidden.has(c.toLowerCase()));
const allowed = btn.distinguishing.some((c) => MOBILE_VISIBLE_ALLOWLIST.has(c));
expect(
hidden || allowed,
`Header button .${btn.distinguishing.join('.')} (id=${btn.id || '?'}) is VISIBLE BY DEFAULT but has ` +
`no mobile-visibility decision.\n` +
` → To hide it on phones: add it to the @media (max-width: 599px) "display: none" block in ` +
`src/web/public/mobile.css (next to .btn-settings / .btn-lifecycle-log).\n` +
` → To keep it visible on phones: add '${btn.distinguishing[0]}' to MOBILE_VISIBLE_ALLOWLIST in ` +
`this test, with a reason.\n` +
`This guard exists because the plan-usage chip and the attachments button both leaked onto the ` +
`mobile header unnoticed.`
).toBe(true);
}
});
it('locks the known phone-hidden header buttons', () => {
for (const cls of KNOWN_PHONE_HIDDEN) {
expect(
phoneHidden.has(cls),
`${cls} must stay hidden on phones — restore its rule in the @media (max-width: 599px) ` +
`display:none block in src/web/public/mobile.css.`
).toBe(true);
}
});
it('allows the explicitly enabled File Viewer button on phones (issue #759)', () => {
// The setting removes the --hidden marker; a separate blanket phone hide
// would override that choice. Default-off and live toggling are exercised
// against the real page in test/mobile/file-browser.test.ts.
expect(phoneHidden.has('btn-file-viewer')).toBe(false);
});
});
// Until #759 the gate kept the File Viewer button off phones with a blanket
// display:none rule and its KNOWN_PHONE_HIDDEN entry. That rule is gone, so what
// keeps the button off a phone nobody switched it on for is now two things, and
// the real-page test that exercises them (test/mobile/file-browser.test.ts) is
// not part of `npm test`:
// 1. the handheld branch of getDefaultSettings() says false, and the header
// reads the setting through that default (a bare `?? true` would show it);
// 2. `html.mobile-init` hides the button until the settings have been applied,
// or it would flash on every phone page load (the template ships it visible).
describe('File Viewer on a phone: off until it is switched on (#759)', () => {
const settingsUi = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf-8');
/** The real getDefaultSettings() of settings-ui.js on one kind of device. */
function defaultsOn(handheld: boolean): Record<string, unknown> {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
MobileDetection: {
isHandheldDevice: () => handheld,
isTouchDevice: () => handheld,
getDeviceType: () => (handheld ? 'mobile' : 'desktop'),
},
window: { matchMedia: () => ({ matches: false }) },
});
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
return app.getDefaultSettings();
}
it('a handheld defaults the button off', () => {
expect(defaultsOn(true).showFileViewerButton).toBe(false);
});
it('a desktop leaves the key unset, which every reader resolves to on', () => {
expect(defaultsOn(false)).not.toHaveProperty('showFileViewerButton');
});
it('the header and the App Settings chip both read the setting through the device default', () => {
const read = 'settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;';
expect(settingsUi).toContain(`const showFileViewerButton = ${read}`);
expect(settingsUi).toContain(`document.getElementById('appSettingsShowFileViewerButton').checked = ${read}`);
// Every reader goes through it: one that skipped the device default would resolve to on.
const reads = [...settingsUi.matchAll(/\bsettings\.showFileViewerButton\b(.{0,40})/g)];
expect(reads.length).toBeGreaterThanOrEqual(2);
for (const [line, rest] of reads) {
expect(rest.startsWith(' ?? defaults.showFileViewerButton'), `"${line}" skips the device default`).toBe(true);
}
});
it('html.mobile-init hides the button until the settings have been applied', () => {
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
let hidesAtStartup = false;
postcss.parse(css).each((node) => {
// Top level only: the rule must apply at every width mobile.css is loaded for.
if (node.type !== 'rule') return;
if (!node.selectors.includes('html.mobile-init .btn-file-viewer')) return;
node.walkDecls('display', (decl) => {
if (decl.value.trim() === 'none' && decl.important) hidesAtStartup = true;
});
});
expect(hidesAtStartup, 'mobile.css must keep .btn-file-viewer in its html.mobile-init display:none rule').toBe(true);
});
});
// The flip side of the policy above: the ONE header control phones do keep has
// to be pressable. The brand "C" is the way back to the home screen and was a
// 0.85rem inline span — roughly a 12x13px target, well under the 44px minimum.
describe('Phone home button tap target (static guard)', () => {
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
/** Declarations applying to `.header-brand .logo` inside a phone media query. */
function phoneLogoDecls(): Map<string, string> {
const decls = new Map<string, string>();
postcss.parse(css).walkAtRules('media', (atRule) => {
if (!appliesToPhone(atRule.params)) return;
atRule.walkRules((rule) => {
if (!/\.header-brand\s+\.logo\s*$/.test(rule.selector)) return;
rule.walkDecls((decl) => decls.set(decl.prop, decl.value));
});
});
return decls;
}
it('gives the brand button a 44x44 hit area on phones', () => {
const decls = phoneLogoDecls();
expect(decls.get('min-width'), 'the "C" home button needs an explicit 44px min-width on phones').toBe('44px');
// A bare inline span ignores width entirely — the box only exists once it
// stops being inline.
expect(decls.get('display')).toBe('inline-flex');
// The other axis is the header's, so the two have to be read together: the
// button is only 44 tall because the phone header is.
expect(decls.get('height')).toBe('var(--header-height)');
});
it('keeps the phone header at 44px, the height that makes that target square', () => {
// The bar was 36px. Shrinking it again silently takes 8px back off every
// header touch target, the home button included.
let phoneHeaderHeight: string | undefined;
postcss.parse(css).walkAtRules('media', (atRule) => {
if (!appliesToPhone(atRule.params)) return;
atRule.walkRules((rule) => {
if (rule.selector.trim() !== ':root') return;
rule.walkDecls('--header-height', (decl) => (phoneHeaderHeight = decl.value.trim()));
});
});
expect(phoneHeaderHeight, '--header-height must be redefined for phones in mobile.css').toBe('44px');
});
});