pediatric-ai-scribe-v3/test/assistant-mobile.test.js
Daniel fed4bd154f
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
fix: recordings that produced nothing, and the boxes that zoomed on iOS
Measured in a real browser against the app rather than reasoned about.

The transcript boxes are contenteditable divs, and an editable div zooms on
focus exactly like an <input>. The earlier 16px sweep covered input, textarea
and select, so every workspace tab still zoomed while the calculators did not —
which is exactly what was reported. Every focusable text control in every tab
now measures 16px at phone width; the count of ones below it is zero.

Three ways a recording could end with nothing to show for it:

  - Safari supports none of the audio/webm types and throws NotSupportedError
    when handed one. Six modules built their own recorder on resume with
    "opus, else audio/webm", so resuming threw there and the recording stopped.
    There is now one codec chain in the app, and no module constructs a
    MediaRecorder of its own.

  - audio-recorder-failed is dispatched on document, and the encounter tab
    stopped its recording on any of them. The assistant's microphone failing
    ended a consultation being recorded in another tab. The recorder now
    travels with the event and the listener checks it is its own.

  - The server answers {success:true, text:''} for silence, and five modules
    assigned that straight into the transcript — emptying the box the browser
    had been filling live. It reads as a recording that vanished. Text is now
    required before overwriting, and a recording that captured nothing says so
    instead of resetting the button over an empty box.

Also: the citation counters were registered on prom-client's default registry
while the app serves its own, so they were never scraped. They read zero at
/metrics now instead of being absent, which is what the Grafana panels need.

And the reference linter passes for the first time, so scripts/e2e.sh gets past
its preflight: KaTeX is vendored (it was referenced by the assistant's LaTeX
rendering but never shipped — three 404s a page load and no math), and the
JavaScript left behind by the removed image picker, saved-chats toggle, image
gallery and visual-output panel is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 00:16:19 +02:00

212 lines
14 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
// On a phone the assistant showed TWO hamburgers, stacked, and neither did
// anything useful: the app's opened a sidebar the assistant had hidden, and the
// assistant's opened a rail that duplicated it.
test('a phone has exactly one menu button', () => {
const assistant = read('public/components/assistant.html');
const index = read('public/index.html');
assert.doesNotMatch(assistant, /btn-assistant-mobile-menu/,
'the assistant no longer adds a drawer button of its own');
assert.equal((index.match(/id="btn-menu-toggle"/g) || []).length, 1,
'the app sidebar button is the only one');
});
test('the app sidebar stays reachable inside the assistant on a phone', () => {
const css = read('public/css/styles.css');
// Hiding it unconditionally is what made the app hamburger open nothing.
assert.match(css, /@media\(min-width:769px\)\{ body\.assistant-workspace \.sidebar\{ display:none; \} \}/,
'the sidebar is only replaced by the assistant rail on desktop');
const hideBlock = css.slice(css.indexOf('body.assistant-workspace .announcement-banner'), css.indexOf('body.assistant-workspace .app-body'));
assert.doesNotMatch(hideBlock, /\.sidebar/, 'nothing hides it unconditionally');
});
test('the collapse control closes the sheet on a phone rather than collapsing a rail', () => {
const app = read('public/js/app.js');
const handler = app.slice(app.indexOf("event.target.closest('[data-menu-toggle]')"), app.indexOf("localStorage.setItem('ped_sidebar_collapsed'"));
assert.match(handler, /if \(window\.innerWidth <= 768\)/);
assert.match(handler, /closeMobileMenus\(\)/, 'it closes whichever menu is open');
const close = app.slice(app.indexOf('function closeMobileMenus()'), app.indexOf('function closeMobileMenus()') + 400);
assert.match(close, /appMenu\.classList\.remove\('open'\)/, 'the workspace menu');
assert.match(close, /layout\.classList\.remove\('mobile-chats-open'\)/, 'and the chat history');
});
test('sources are reachable on a phone instead of deleted', () => {
const css = read('public/css/assistant.css');
const mobile = css.slice(css.indexOf('@media (max-width: 640px)'));
// display:none left every citation pointing at nothing.
assert.doesNotMatch(mobile.slice(0, 2000), /\.assistant-side \{ display:none; \}/);
assert.match(mobile, /body\.assistant-sources-open \.assistant-side \{ display:flex; height:min\(46vh,340px\); \}/,
'they open into a band of their own rather than covering the composer');
assert.doesNotMatch(mobile, /\.assistant-side \{ position:fixed;/,
'a fixed overlay sat on top of the question box');
const sources = mobile.slice(mobile.indexOf('.assistant-sources {'));
assert.match(sources.slice(0, 200), /overflow-y:auto/, 'the list inside the band scrolls');
const js = read('public/js/clinicalAssistant.js');
assert.match(js, /button\.hidden = !\(citationsOn && count > 0\);/,
'the opener appears only when an answer has citations');
assert.match(js, /event\.target\.closest\('\.assistant-cite'\)/, 'and tapping a citation opens it');
// A sheet with no way out is a trap.
assert.match(js, /if \(event\.key !== 'Escape'\) return;[\s\S]{0,160}assistant-sources-open/);
assert.match(js, /if \(!document\.body\.classList\.contains\('assistant-sources-open'\)\) return;/,
'tapping away closes it');
});
test('the mobile menu is sized for a thumb', () => {
const css = read('public/css/styles.css');
const mobile = css.slice(css.indexOf('/* ── Mobile menu ─'));
assert.match(mobile, /\.sidebar\{ width:min\(85vw,320px\)/, 'a sheet the width of the chat drawer, not a narrow rail');
assert.match(mobile, /\.tab-btn\{ padding:11px 12px; font-size:14px/, 'rows are tappable');
// Plain, like the chat history: text rows and a grey current row.
assert.match(mobile, /\.tab-btn i\{ display:none; \}/);
assert.match(mobile, /\.tab-btn\.active\{ border-left:none; background:var\(--g100\)/);
assert.match(mobile, /env\(safe-area-inset-bottom\)/, 'and clear of the home indicator');
});
test('the account menu escapes the collapsed rail instead of being clipped', () => {
const css = read('public/css/styles.css');
// .sidebar has overflow:hidden and the collapsed rail is 52px, so a menu laid
// out inside it was cut to a sliver — hiding Settings, FAQ and Log out behind
// the avatar exactly when the rail is narrow.
const sidebar = css.split('\n').find(l => l.startsWith('.sidebar{'));
assert.match(sidebar, /overflow:hidden/, 'the rail does clip, so the menu must escape it');
assert.match(css, /body\.menu-hidden \.account-menu \{ position:fixed;/,
'the collapsed menu is positioned against the viewport, not the rail');
assert.match(css, /body\.menu-hidden \.account-menu \{[^}]*width:210px/, 'and keeps a readable width');
});
test('the account card sits at the same height in both rails', () => {
const css = read('public/css/styles.css');
// The app column pads its bottom; the assistant rail did not, so its card
// started lower than the app's.
const app = css.split('\n').find(l => l.startsWith('.account-card {'));
const assistant = css.split('\n').find(l => l.startsWith('.assistant-history .account-card {'));
assert.match(app, /padding:8px;/);
assert.match(assistant, /padding:8px 0;/, 'same vertical padding, so the cards align');
assert.match(css, /\.account-card-btn \{ min-height:44px; \}/,
'and the same row height in both');
});
test('a desktop collapse does not follow the user onto a phone', () => {
const css = read('public/css/styles.css');
// menu-hidden is remembered in localStorage across viewports. Unscoped, its
// 52px-rail rules hid .sidebar-tabs, the Assistant/Workspace switch and the
// account name INSIDE the phone sheet — the menu opened onto a strip of icons
// and every link looked broken.
const start = css.indexOf('@media (min-width:769px) {\n /* Collapsing is a desktop idea');
assert.ok(start !== -1, 'the collapse rules are wrapped in a desktop-only query');
const block = css.slice(start, css.indexOf('\n}\n', start));
assert.match(block, /body\.menu-hidden \.sidebar-tabs/, 'including the one that hid the links');
assert.match(block, /body\.menu-hidden \.assistant-mode-switch/, 'and the one that hid Workspace');
assert.match(block, /body\.menu-hidden \.account-menu \{ position:fixed;/);
// Nothing outside that query may still collapse the phone sheet.
const outside = css.slice(0, start) + css.slice(css.indexOf('\n}\n', start));
assert.doesNotMatch(outside, /body\.menu-hidden \.sidebar \{ width:/);
});
test('the Workspace pill works on a phone', () => {
const app = read('public/js/app.js');
// The switch lives inside the sheet, so the view it changes is behind the
// sheet until the sheet closes.
assert.match(app, /\[data-assistant-mode\]'\)\)\) return;\s*\n\s*if \(window\.innerWidth <= 768\) closeMobileMenus\(\);/);
const css = read('public/css/assistant.css');
// The launcher used to be display:none below 640px, so the pill toggled a
// view that could never appear.
assert.doesNotMatch(css, /body\.assistant-mode-workspace \.assistant-workspace-view \{ display:none; \}/);
assert.match(css, /\.assistant-workspace-cards \{ grid-template-columns:1fr; \}/,
'and its cards stack into one column');
});
test('on a phone the menu follows the view: chat history in the assistant', () => {
const app = read('public/js/app.js');
// The assistant used to open the workspace list, so a saved chat was
// unreachable on a phone.
const handler = app.slice(app.indexOf("e.target.closest('#btn-menu-toggle')"), app.indexOf("e.target.closest('#btn-sidebar-close')"));
assert.match(handler, /window\.innerWidth <= 640 && document\.body\.classList\.contains\('assistant-workspace'\)/);
assert.match(handler, /railLayout\.classList\.toggle\('mobile-chats-open'\)/, 'the assistant opens its chat drawer');
assert.match(handler, /else if \(sidebar\) \{\s*sidebar\.classList\.toggle\('open'\)/, 'everywhere else, the workspace list');
assert.match(app, /if \(window\.innerWidth <= 768\) closeMobileMenus\(\);\s*\n\s*return true;/, 'choosing a page closes it');
const css = read('public/css/assistant.css');
const mobile = css.slice(css.indexOf('@media (max-width: 640px)'));
// The name card is pinned at the foot of both menus: only the list scrolls.
assert.match(mobile, /\.assistant-history \{ position:fixed;[^}]*padding:env\(safe-area-inset-top, 0px\) 0 0; overflow:hidden;/, 'the drawer itself does not scroll, and clears the status bar');
assert.match(mobile, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto;/, 'the chat list does');
assert.match(mobile, /\.assistant-drawer-close \{ display:none !important; \}/, 'one close control, the « in the head row');
});
test('source cards fit the phone: long names wrap, nothing scrolls sideways or inside', () => {
const css = read('public/css/assistant.css');
// An auto grid column grew to the longest unbroken file name, so every card
// ran off the right edge of an iPhone.
assert.match(css, /^\.assistant-sources \{[^}]*grid-template-columns:minmax\(0,1fr\);[^}]*overflow-x:hidden;/m);
assert.match(css, /^\.assistant-source \{ min-width:0; overflow-wrap:anywhere; word-break:break-word;/m,
'long names break onto new lines, with the pre-15.4 Safari fallback');
const mobile = css.slice(css.indexOf('@media (max-width: 640px)'));
assert.match(mobile, /\.assistant-source-excerpt \{ max-height:none; overflow:visible; \}/, 'no scroll box inside the scrolling list');
assert.match(mobile, /\.assistant-sources \{[^}]*overflow-x:hidden; touch-action:pan-y;/, 'and no sideways pan');
});
test('the phone top bar is solid behind the status bar too', () => {
const index = read('public/index.html');
const css = read('public/css/styles.css');
// Scrolling an admin page on an iPhone showed text flowing at the top of the
// screen: the page is drawn under the status bar (viewport-fit=cover) and the
// theme colour was the removed header's blue.
assert.match(index, /viewport-fit=cover/);
assert.match(index, /<div class="mobile-topbar" aria-hidden="true"><\/div>/, 'a real element, which iOS looks for at the top edge');
assert.match(index, /<meta name="theme-color" content="#ffffff">/);
assert.match(read('public/manifest.json'), /"theme_color": "#ffffff"/);
assert.doesNotMatch(css, /#main-app::before/);
assert.match(css, /:root\{ --mobile-bar:calc\(48px \+ env\(safe-area-inset-top, 0px\)\); \}/, 'the row reaches up behind the status bar');
assert.match(css, /\.btn-menu-toggle \{[^}]*top:calc\(6px \+ env\(safe-area-inset-top, 0px\)\)/, 'and the button sits below it');
assert.match(css, /\.sidebar\{ width:min\(85vw,320px\); left:calc\(-1 \* min\(85vw,320px\)\); padding:env\(safe-area-inset-top, 0px\) 0 0; \}/);
assert.match(read('public/css/assistant.css'), /\.assistant-sources-fab \{[^}]*top:calc\(8px \+ env\(safe-area-inset-top, 0px\)\)/);
});
test('no text control is small enough to make iOS zoom the page', () => {
const css = read('public/css/styles.css');
// iOS Safari zooms the whole page when a field under 16px takes focus and
// never zooms back, which is why fixed chrome (the menu button) ended up off
// screen after typing. 16px is the threshold.
const rule = css.slice(css.indexOf('/* iOS Safari zooms the whole page'));
assert.match(rule, /@media \(max-width:768px\) \{\s*\n\s*input:not\(\[type="checkbox"\]\)/);
assert.match(rule, /font-size:16px !important; \}/,
'it must beat per-component rules such as .demo-field select');
// An editable div zooms exactly like an input. Every transcript box in the
// workspace is one, so leaving them out meant every tab still zoomed on focus
// while the calculators (plain inputs) did not.
assert.match(rule, /\[contenteditable="true"\], \[contenteditable=""\]/,
'the contenteditable transcript boxes need it too');
assert.match(read('public/css/styles.css'), /\.editable-box\{[^}]*font-size:13px/,
'the boxes are 13px by default, which is what makes the override necessary');
// Pinch-zoom stays available: the fix is the font size, not a locked viewport.
assert.doesNotMatch(read('public/index.html'), /user-scalable=no|maximum-scale=1/);
// The composer set 15px AFTER its own mobile override, so the base rule won.
const assistant = read('public/css/assistant.css');
assert.doesNotMatch(assistant, /#assistant-input \{ flex:1 1 auto;[^}]*font-size:15px/);
assert.match(assistant, /@media \(min-width:641px\) \{ #assistant-input \{ font-size:15px; \} \}/);
});
test('the SSO settings fit a phone instead of running off the side', () => {
const html = read('public/components/admin.html');
const sso = html.slice(html.indexOf('Single Sign-On'), html.indexOf('Email Templates'));
// These rows were laid out inline: flex with a 160px label and an input that
// would not shrink, so on a phone the row was wider than the screen with
// nothing to scroll and no way to reach the rest.
assert.doesNotMatch(sso, /min-width:160px/);
assert.doesNotMatch(sso, /display:flex;align-items:center;gap:12px;/);
assert.equal((sso.match(/class="admin-row"/g) || []).length, 6, 'every row uses the pattern that stacks');
assert.match(sso, /class="admin-row-label"/);
assert.match(sso, /class="admin-control"/);
// .admin-row stacks below 640px and its controls stop constraining width.
const css = read('public/css/styles.css');
assert.match(css, /\.admin-row \{ grid-template-columns:1fr; \}/);
assert.match(css, /\.admin-control \{ max-width:none; \}/);
});