const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { JSDOM } = require('jsdom'); const { marked } = require('marked'); const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); function workspace(t) { // Real .tab-btn elements: the rail and cards mirror these, so a harness // without them cannot show whether the mirroring works. const tabs = '' + '' + '' + ''; const dom = new JSDOM(tabs + '
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); const window = dom.window; window.eval(read('public/js/accountBoundary.js')); assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true); window.marked = marked; window.markdownit = require('markdown-it'); window.DOMPurify = require('dompurify')(window); window.matchMedia = () => ({ matches: true }); const fetched = []; const activated = []; const events = []; const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController, setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]], createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), fetchAssistantStatus: async () => ({ success: true }), fetchAssistantExamples: async () => ({ success: true, examples: [] }), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }), saveAssistantChat: async () => ({ success: true }), fetch: async url => { fetched.push(url); return new Response(read('public/components/learning.html')); } }; vm.createContext(context); for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) { vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); } context.bindEvents(); window.activateTab = tab => activated.push(tab); window.document.addEventListener('tabChanged', e => events.push(e.detail.tab)); t.after(() => window.close()); return { context, document: window.document, window, fetched, activated, events }; } test('assistant area is an OWUI-style three-column workspace with a slim go-back top bar', t => { const app = workspace(t); const layout = app.document.querySelector('.assistant-layout'); assert.ok(layout); const columns = [...layout.children].map(el => el.className.split(' ')[0]); assert.ok(columns.includes('assistant-history'), 'left history rail'); assert.ok(columns.includes('assistant-main'), 'center chat column'); assert.ok(columns.includes('assistant-side'), 'right image/sources column'); const left = app.document.querySelector('.assistant-history'); assert.ok(left.querySelector('#assistant-saved-chats'), 'saved chats live in the left rail'); assert.equal(left.querySelector('#btn-assistant-clear') !== null, true, 'New chat sits at the top of the rail'); assert.equal(left.firstElementChild.id, 'btn-assistant-drawer-close', 'the drawer leads with its close control'); const right = app.document.querySelector('.assistant-side'); assert.equal(right.querySelector('#assistant-visual-output'), null, 'no image display in the right column — sources only'); assert.ok(right.querySelector('#assistant-sources'), 'sources panel in the right column'); assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column'); // No bar above the layout at all: the app page has only the blue header, so a // second bar here made the assistant taller and left dead space on top. assert.equal(app.document.querySelector('.assistant-topbar'), null, 'no assistant-only top bar'); // Go back is gone: the Assistant/Workspace switch is how you leave, so a // second exit control in the topbar was one affordance too many. assert.equal(app.document.querySelector('#btn-assistant-goback'), null, 'no Go back in the topbar'); assert.ok(app.document.querySelector('[data-assistant-mode="workspace"]'), 'the mode switch replaces it'); assert.equal(app.document.querySelector('.assistant-history #btn-assistant-create-image'), null, 'Create image moved into the + menu with the other conversation actions'); // The top bar is gone entirely: everything that acts on the conversation moved // into the composer's + menu, so both views start at the same vertical // position and switching cannot nudge the page up or down. assert.equal(app.document.querySelector('.assistant-toolbar'), null, 'no top bar above the transcript'); const menu = app.document.querySelector('#assistant-plus-menu'); assert.ok(menu, 'the + menu exists'); const items = [...menu.querySelectorAll('button')].map(b => b.id); assert.deepEqual(items, ['btn-assistant-create-image', 'btn-assistant-takehome', 'btn-assistant-export-pdf', 'btn-assistant-download-chat'], 'every action on the conversation lives in the + menu'); assert.ok(menu.querySelector('label[for="assistant-attach-input"]'), 'and attaching images with them'); assert.equal(menu.hidden, true, 'closed until asked for'); const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]'); assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen'); }); test('opening the assistant replaces the main menu with the saved chats', async t => { const app = workspace(t); app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } })); assert.ok(app.document.body.classList.contains('assistant-workspace'), 'fullscreen workspace class applied'); app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } })); assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'leaving the assistant restores the app chrome'); }); test('one menu toggle serves both the app sidebar and the assistant rail', () => { const index = read('public/index.html'); const rail = read('public/components/assistant.html'); for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) { assert.match(markup, /data-menu-toggle/, where + ' has the toggle'); assert.match(markup, /title="Hide menu"/, where + ' names it Hide menu'); } // The app used to carry a pin button AND a floating expand button, with a // third control inside the assistant — three affordances for one idea. assert.doesNotMatch(index, /btn-sidebar-pin|btn-sidebar-expand/, 'the old pin/expand pair is gone'); assert.doesNotMatch(rail, /btn-assistant-toggle-history/, 'and the assistant-only toggle with it'); const app = read('public/js/app.js'); assert.match(app, /classList\.toggle\('menu-hidden'\)/, 'one class drives both'); assert.match(app, /'Show menu' : 'Hide menu'/, 'the label flips so the collapsed state is escapable'); // Shared chrome lives in styles.css: the app page never loads assistant.css. const css = read('public/css/styles.css'); assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/); // The toggle lives inside the box it collapses, so it must leave that box or // there is no way to bring the menu back. // The toggle is a head-row icon button, so it needs no position or margin of // its own — styling it twice gave it a second size that fought the row. assert.match(css, /\.menu-icon-btn \{[^}]*width:28px; height:28px/, 'one styling source'); assert.doesNotMatch(css, /^\.assistant-menu-toggle \{ display:flex/m, 'no duplicate box styling'); assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'and collapsed keeps it in the rail rather than floating over content'); // The collapsed rail keeps its icons; only the labelled lists go. assert.match(css, /body\.menu-hidden \.sidebar-tabs,/, 'the tab list collapses'); assert.match(css, /body\.menu-hidden \.menu-brand h1,/, 'and the wordmark, leaving the mark'); assert.match(read('public/js/app.js'), /'Show menu' : 'Hide menu'/, 'and it says how to get back'); }); test('mobile drawer rows carry no chat icons and keep the options menu', () => { const css = read('public/css/assistant.css'); assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:calc\(100dvh - var\(--mobile-bar, 0px\)\);/.test(css), 'mobile layout locks the viewport, below the menu row'); assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile'); }); test('collapsing the rail does not hand the chat column to the citations panel', () => { const fs = require('node:fs'); const path = require('node:path'); const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8'); const collapsed = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed .assistant-history')); assert.ok(collapsed, 'the collapsed rail rule exists'); // display:none removes the rail from grid flow, so .assistant-main inherits the // 0 column and #assistant-sources takes the 1fr — the chat vanishes and the // citations render in its place. assert.doesNotMatch(collapsed, /display:\s*none/, 'the rail must stay a grid item'); assert.match(collapsed, /width:0/); assert.match(collapsed, /visibility:hidden/); const track = css.split('\n').find(l => l.startsWith('.assistant-layout.history-collapsed {')); assert.match(track, /grid-template-columns:0 minmax\(0,1fr\) 330px/, 'three tracks for three items'); }); test('the assistant and the app start at the same top edge', () => { const rail = read('public/components/assistant.html'); const css = read('public/css/assistant.css'); // The assistant used to carry its own bar on top of the shared blue header, // so switching modes changed the height of everything below it. assert.doesNotMatch(rail, /assistant-topbar/, 'no second bar in the markup'); assert.doesNotMatch(css, /assistant-topbar/, 'and none left in the stylesheet'); assert.match(css, /\.assistant-history \{ display:grid; gap:10px; align-content:start; position:sticky; top:0; \}/, 'the rail sticks to the top of the layout, not below a bar'); // The status moved into the composer, next to the work it describes. assert.match(rail, /assistant-composer-footer[\s\S]*?id="assistant-status"/, 'the ready/busy indicator lives in the composer now'); }); test('the menu toggle uses an icon that reads as a menu', () => { for (const file of ['public/index.html', 'public/components/assistant.html']) { const markup = read(file); const button = markup.split('\n').find(l => l.includes('data-menu-toggle')); assert.ok(button, file + ' has the toggle'); // fa-table-columns draws a split-pane glyph that says nothing about what the // click does. Angles point the way the panel moves. assert.doesNotMatch(button, /fa-table-columns/, file + ' avoids the split-pane glyph'); assert.match(button, /fa-angles-left/, file); } assert.match(read('public/css/assistant.css'), /\.assistant-menu-toggle\.is-collapsed i \{ transform:rotate\(180deg\); \}/, 'and it turns to show the direction it will act in'); // Collapsed, the toggle is the only control left, so it must stay reachable. assert.match(read('public/css/styles.css'), /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'collapsed narrows to an icon rail, so the toggle stays reachable in place'); }); test('workspace mode does not override the mobile layout with a desktop header offset', () => { const fs = require('node:fs'); const path = require('node:path'); const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8'); // body.assistant-workspace .assistant-layout is both later in the file and more // specific than the .assistant-layout rule inside @media (max-width:640px), so // the mobile rule needs a matching-specificity override to win at all. // Both now subtract the app header, which the assistant keeps rather than hides. const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px));'); const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-bar, 0px))'); assert.ok(desktop > 0, 'the desktop workspace rule exists'); assert.ok(override > desktop, 'the mobile override comes after it, so it wins'); const rule = css.slice(override, css.indexOf('}', override)); assert.match(rule, /display:flex/, 'the mobile flex layout is restored'); assert.match(rule, /grid-template-rows:none/, 'and the grid rows do not linger on a flex box'); }); test('Go back is gone: Workspace is how you leave the assistant', () => { const fs = require('node:fs'); const path = require('node:path'); const root = path.join(__dirname, '..'); const html = fs.readFileSync(path.join(root, 'public/components/assistant.html'), 'utf8'); assert.doesNotMatch(html, /btn-assistant-goback/, 'no redundant Go back button'); const js = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8'); assert.doesNotMatch(js, /goBackToMainMenu/, 'and no dead handler left behind'); }); test('the Assistant/Workspace switch is the same control in both places', () => { const root = path.join(__dirname, '..'); const index = read('public/index.html'); const rail = read('public/components/assistant.html'); // The app IS workspace mode. The switch appears in the app sidebar with // Workspace highlighted and in the assistant rail with Assistant highlighted // — the only difference between the two screens. for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) { assert.match(markup, /class="assistant-mode-switch"/, where + ' has the switch'); assert.match(markup, /data-assistant-mode="assistant"/, where); assert.match(markup, /data-assistant-mode="workspace"/, where); } const appActive = index.match(/class="active"[^>]*data-assistant-mode="(\w+)"/); assert.equal(appActive && appActive[1], 'workspace', 'the app highlights Workspace'); const railActive = rail.match(/class="active"[^>]*data-assistant-mode="(\w+)"/); assert.equal(railActive && railActive[1], 'assistant', 'the assistant highlights Assistant'); assert.doesNotMatch(index, /data-tab="assistant"/, 'the old AI Assistant nav entry is gone'); }); test('the switch navigates from the app and opens the launcher inside the assistant', () => { const app = read('public/js/app.js'); const handler = app.slice(app.indexOf('// Assistant / Workspace switch'), app.indexOf('window.activateTab = activateTab;')); // In the app you ARE the workspace, so only the Assistant pill goes anywhere. // It switches in place rather than setting window.location, which reloads the // document and would end a running recording. assert.match(handler, /if \(!wantsAssistant\) return;/); assert.match(handler, /if \(typeof window\.activateTab === 'function'\) window\.activateTab\('assistant'\);/); assert.match(handler, /else window\.location\.href = '\/assistant';/); assert.match(handler, /assistantShowWorkspaceLauncher\(!wantsAssistant\)/, 'in the assistant it swaps the view in place'); // The in-page mode machinery is gone, and with it the whole class of // hidden-vs-display bugs that made the chat vanish and never come back. const assistant = read('public/js/clinicalAssistant.js'); for (const dead of ['setAssistantMode', 'renderWorkspaceLinks', 'goBackToMainMenu']) { assert.ok(!assistant.includes(dead), dead + ' is gone'); } // Cards are clinical work surfaces only. assert.match(assistant, /NON_WORK_TABS = \['settings', 'admin', 'docs', 'faq', 'cms'\]/, 'no account or content-management tabs among the cards'); const css = read('public/css/assistant.css'); assert.match(css, /body\.assistant-mode-workspace #assistant-chat-view \{ display:none; \}/, 'the launcher replaces the chat rather than stacking with it'); }); test('the assistant uses the app accent, so neither screen looks like a different product', () => { const styles = read('public/css/styles.css'); assert.match(styles, /\.tab-btn\.active\{background:var\(--blue-light\);color:var\(--blue\)/, 'the app accent is blue'); assert.match(read('public/css/styles.css'), /\.assistant-mode-switch button\.active \{ background:white; color:var\(--blue\)/, 'the switch highlights in the app accent'); const css = read('public/css/assistant.css'); assert.match(css, /\.assistant-msg\.user \.assistant-bubble \{ white-space:pre-wrap; background:var\(--blue\)/); // Citations keep their own purple chip so evidence stays distinct from // interactive blue; nothing else may reintroduce a second accent. const stray = css.split('\n').filter(l => l.includes('var(--purple') && !l.includes('assistant-cite')); assert.deepEqual(stray, [], 'no second accent outside citation chips'); }); test('chrome rendered on the main app page is styled by the stylesheet that page loads', () => { const index = read('public/index.html'); const styles = read('public/css/styles.css'); const assistantCss = read('public/css/assistant.css'); // assistant.css is pulled in by the assistant COMPONENT, so it is absent on // "/". Anything the app page renders must be styled from styles.css or it // shows up as an unstyled native control. assert.doesNotMatch(index, /css\/assistant\.css/, 'the app page does not load assistant.css'); for (const cls of ['assistant-mode-switch', 'assistant-menu-toggle']) { assert.match(index, new RegExp('class="[^"]*' + cls), cls + ' is rendered on the app page'); assert.match(styles, new RegExp('\\.' + cls + '[\\s{]'), cls + ' must be defined in styles.css'); // Defined in both files, the two copies drift and one silently wins. assert.ok(!assistantCss.includes('.' + cls + ' {'), cls + ' must not be duplicated in assistant.css'); } assert.match(styles, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'the collapse state is styled where the app can see it'); }); test('the model selector sits with send, and only when there is a choice', () => { const html = read('public/components/assistant.html'); // ChatGPT/OWUI placement: the model belongs beside the send control in the // composer, not in a bar above the transcript. const right = html.slice(html.indexOf('assistant-composer-right'), html.indexOf('')); for (const id of ['assistant-model-pill', 'btn-assistant-send']) { assert.ok(right.includes(id), id + ' is on the composer right'); } assert.match(html, /id="assistant-model-pill"[^>]*hidden/, 'hidden until there is something to pick'); const js = read('public/js/clinicalAssistant.js'); assert.match(js, /One model means no choice: hide the selector entirely/); assert.match(js, /var show = Array\.isArray\(allowed\) && allowed\.length > 1;/); assert.match(js, /if \(pill\) pill\.hidden = !show;/, 'the pill follows the select'); assert.doesNotMatch(js, /assistant-model-label/, 'the old always-on model label is gone'); }); test('nothing sits above the transcript, so switching cannot shift the page', () => { const css = read('public/css/assistant.css'); // Both views fill the same box from the same top edge. With a toolbar above // one of them, switching moved everything down by its height. assert.match(css, /#assistant-chat-view \{ display:grid; grid-template-rows:minmax\(0,1fr\) auto;/, 'chat is transcript + composer, nothing above'); assert.doesNotMatch(css, /^\.assistant-toolbar/m, 'no toolbar styling left'); assert.match(css, /\.assistant-workspace-view \{ flex:1 1 auto; min-height:0;/, 'the launcher fills the same box'); const html = read('public/components/assistant.html'); assert.doesNotMatch(html, /assistant-toolbar/, 'and no toolbar markup'); }); test('both modes share one surface, so switching changes only the content', () => { const css = read('public/css/assistant.css'); // The tiled ground belongs to the panel itself, not to one view's empty state, // so the chat and the workspace launcher sit on the same background. assert.match(css, /\.assistant-main\.card \{\n\s*background-image:linear-gradient/, 'the tiles belong to the shared panel'); assert.doesNotMatch(css, /#assistant-chat-view:has[^\n]*\{\n\s*background-image/, 'not to one view only'); // The composer floats with room around it rather than pinned to the edge. assert.match(css, /\.assistant-composer \{ margin:0 auto 20px; max-width:760px; width:calc\(100% - 40px\)/); assert.match(css, /\.assistant-messages:not\(:has\(\.assistant-msg\)\) \{ display:flex; flex-direction:column; justify-content:center;/, 'an empty transcript centres rather than pinning the composer to the top'); }); test('one menu width, so the content does not shift sideways on switch', () => { const styles = read('public/css/styles.css'); const css = read('public/css/assistant.css'); const sidebar = styles.split('\n').find(l => l.startsWith('.sidebar{')); assert.match(sidebar, /width:210px/, 'the app sidebar is 210px'); // The assistant rail was 260px, so every switch moved the chat sideways. assert.match(css, /grid-template-columns:210px minmax\(0,1fr\) 330px/, 'the rail matches it'); assert.doesNotMatch(css, /grid-template-columns:260px/, 'no 260px rail left'); }); test('Create image sits with the other conversation actions', () => { const html = read('public/components/assistant.html'); const menu = html.slice(html.indexOf('assistant-plus-menu'), html.indexOf('')); // It acts on the conversation like take home and export, so it belongs in the // + menu rather than as its own button on the rail. assert.match(menu, /btn-assistant-create-image/, 'Create image is in the + menu'); const rail = html.slice(0, html.indexOf('assistant-main')); assert.doesNotMatch(rail, /btn-assistant-create-image/, 'and not on the rail'); assert.doesNotMatch(read('public/css/assistant.css'), /^\.assistant-create-image \{/m, 'its button styling is gone'); }); test('one menu, ending the same way in both views', () => { const index = read('public/index.html'); const rail = read('public/components/assistant.html'); // The view decides what the menu LISTS; everything structural about the menu // itself is identical, so switching never redraws the frame. for (const [markup, where] of [[index, 'app sidebar'], [rail, 'assistant rail']]) { assert.match(markup, /class="account-card"/, where + ' ends with the account card'); assert.match(markup, /data-account-tab="settings"/, where + ' offers Settings'); assert.match(markup, /data-account-logout/, where + ' offers Log out'); assert.match(markup, /data-menu-toggle/, where + ' has the menu toggle'); assert.match(markup, /assistant-mode-switch/, where + ' has the view switch'); } const app = read('public/js/app.js'); assert.match(app, /if \(user\.role === 'admin'/, 'Admin is offered only to admins'); assert.match(app, /logoutBtn = document\.getElementById\('btn-logout'\)/, 'log out reuses the existing flow rather than a second implementation'); // Reaching an app tab from the assistant has to leave the assistant. assert.match(app, /if \(tab && window\.location\.pathname === '\/assistant'\)/); }); test('there is no header bar; the brand heads the menu instead', () => { const index = read('public/index.html'); const css = read('public/css/styles.css'); // Settings and Log out moved to the account card, so a whole band of chrome // had nothing left to hold and became content. assert.doesNotMatch(index, /
/, 'no header element'); assert.doesNotMatch(css, /^\.app-header\{/m, 'and no header styling'); for (const [markup, where] of [[index, 'app menu'], [read('public/components/assistant.html'), 'assistant rail']]) { assert.match(markup, /class="menu-brand"/, where + ' carries the brand'); } // The canonical handlers stay so nothing that calls them breaks. assert.match(index, /id="btn-logout" hidden/, 'logout handler retained, hidden'); assert.match(index, /id="btn-settings" hidden/); // Nothing sits above the content, so no layout should still subtract a header. for (const stale of [/calc\(100vh - 66px\)/, /calc\(100vh - 52px\)/, /calc\(100dvh - \d+px\)/]) { assert.doesNotMatch(css, stale, 'no stale header offset in styles.css'); assert.doesNotMatch(read('public/css/assistant.css'), stale, 'none in assistant.css'); } // The only offset left is the phone's menu row, a variable that is 0px on desktop. assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: calc\(100vh - var\(--mobile-bar, 0px\)\);/, 'the layout owns the full viewport'); assert.match(css, /:root \{ --mobile-bar:0px; \}/, 'which is zero outside the phone breakpoint'); }); test('every view sits in the same shell, so opening a menu item is not a new app', () => { const css = read('public/css/styles.css'); // Each tab used to be a plain white page while the assistant and the workspace // launcher sat on a tiled card, so any menu click changed the whole face. // The ground covers the whole page, menu included, from one origin: a second // tile on .main-content would start 210px in and seam against the menu's. assert.match(css, /\.app-body\{[^}]*background-image:linear-gradient/, 'the tiled ground belongs to the page'); assert.doesNotMatch(css, /\.main-content\{[^}]*background-image/, 'and only to the page'); // Flat menu on desktop, in both views; the phone sheet stays white. assert.match(css, /@media\(min-width:769px\)\{ \.sidebar\{ background:transparent; border-right:none; \} \}/); assert.match(css, /\.main-content > \.tab-content\.active\{[^}]*border:1px solid var\(--g200\);border-radius:14px/, 'and every tab gets the same card edge'); // ...and the same tiled-white surface as the assistant's chat panel, so a // workspace page does not turn plain white on entry. const tile = 'linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px)'; const card = css.slice(css.indexOf('.main-content > .tab-content.active{'), css.indexOf('}', css.indexOf('.main-content > .tab-content.active{'))); assert.ok(card.includes('background-color:white') && card.includes(tile) && card.includes('background-size:28px 28px'), 'page card is tiled white'); const panel = read('public/css/assistant.css'); // The top-level rule, not the phone override that also names .assistant-main.card. const chat = (panel.match(/^\.assistant-main\.card \{[^}]*\}/m) || [''])[0]; assert.ok(chat.includes('background-color:white') && chat.includes(tile) && chat.includes('background-size:28px 28px'), 'the same fill as the chat panel'); // The assistant brings a full-bleed layout, so it replaces the shell card // rather than nesting a second one inside it. assert.match(css, /\.main-content > #assistant-tab\.active\{padding:0;border:none/); assert.match(read('public/css/assistant.css'), /\.assistant-main\.card \{[\s\S]{0,220}?border:1px solid var\(--g200\); border-radius:14px/, 'the assistant panel carries the matching edge itself'); }); test('the new chrome is handled on phones', () => { const css = read('public/css/styles.css'); const mobile = css.slice(css.indexOf('@media(max-width:768px)')); // The sidebar slides in whole on mobile, so the collapse control closes it // instead, and replaces the old "Menu" + X header as the one close control. assert.doesNotMatch(mobile, /\.assistant-menu-toggle\{display:none/); assert.match(mobile, /\.sidebar-header\{ display:none; \}/); assert.match(mobile, /\.account-card\{position:sticky;bottom:0/, 'the account card stays reachable'); assert.match(mobile, /\.menu-search-panel\{width:100%/, 'the palette is full-bleed'); assert.match(mobile, /\.sidebar-nav\{flex:1 1 auto;min-height:0;\}/, 'the menu scrolls, the card does not float away'); }); test('the rail scrolls in both modes, not only after leaving for the app', () => { const css = read('public/css/assistant.css'); // The app sidebar has always scrolled. The assistant rail had no overflow // rule, so its list of every app tab ran off the bottom — and scrolling only // appeared once you left for the app. assert.match(css, /\.assistant-rail-workspace \{[^}]*flex:1 1 auto; min-height:0; overflow-y:auto/, 'the workspace list scrolls'); assert.match(css, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto/, 'and so does the saved-chat list'); // The rail itself is the flex column that makes those two scroll. assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; padding-bottom:8px; \}/, 'the rail is the flex column that makes those scroll, with the same bottom gap as the app'); }); test('the menu can always be reopened after it is hidden', () => { const html = read('public/index.html'); const css = read('public/css/styles.css'); // This has broken twice by moving the toggle deeper and leaving a hide rule // pointed at its old depth. So walk the real ancestor chain and prove no rule // hides anything between the toggle and the sidebar root. const toggleAt = html.indexOf('data-menu-toggle'); assert.ok(toggleAt > 0, 'the toggle exists'); const before = html.slice(0, toggleAt); const ancestors = []; for (const m of before.matchAll(/<(?:div|nav)\s+class="([^"]+)"/g)) ancestors.push(m[1].split(/\s+/)); // Innermost ancestors that are still open at the toggle. const chain = ancestors.slice(-3).flat(); assert.ok(chain.includes('menu-head'), 'the toggle sits in the menu head'); assert.match(css, /body\.menu-hidden \.sidebar \{ width:52px; \}/, 'collapsed is a rail, not a disappearance'); const hideRules = css.split('\n').filter(l => l.startsWith('body.menu-hidden') && l.includes('display:none')); for (const rule of hideRules) { const selector = rule.slice(0, rule.indexOf('{')).trim(); // A rule that hides a class on the toggle's own ancestor chain must exempt // the branch the toggle is on. for (const cls of ['menu-head', 'sidebar-nav']) { if (new RegExp('\\.' + cls + '\\s*\\{|\\.' + cls + '\\s*,|\\.' + cls + '$').test(selector)) { assert.fail('rule hides the toggle\'s ancestor .' + cls + ': ' + selector); } } } // And the exemptions that keep it reachable are actually present. // Everything that keeps the rail usable while collapsed stays visible. for (const kept of ['.menu-head', '.menu-brand', '.account-card']) { assert.ok(!hideRules.some(r => r.slice(0, r.indexOf('{')).trim().endsWith(kept)), kept + ' stays visible in the collapsed rail'); } }); test('search reaches the things inside a tab, not just the tab', () => { const app = read('public/js/app.js'); // People look for "bili", not "Calculators". Sub-navigation is read // generically from whatever a loaded component exposes, so this covers every // tab with sub-navigation rather than one hard-coded list. // Listing, matching and opening all read one attribute list, so they cannot // drift apart when a component's sub-navigation is added. assert.match(app, /var SUB_NAV_ATTRS = \['data-calc', 'data-subtab', 'data-section', 'data-em'\];/); assert.match(app, /var SUB_NAV_SELECTOR = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '\]'; \}\)\.join\(', '\) \+ ', \.calc-nav-pill';/); assert.match(app, /var selector = SUB_NAV_ATTRS\.map\(function\(attr\) \{ return '\[' \+ attr \+ '="' \+ sub \+ '"\]'; \}\)\.join\(', '\);/); assert.match(app, /function subItemsFor\(/); assert.match(app, /if \(!tabEl \|\| !tabEl\.dataset\.loaded\) return \[\];/, 'an unloaded component exposes nothing, so it is skipped rather than guessed at'); assert.match(app, /function warmSearchableTabs\(/, 'and those components are warmed when the palette opens'); // Opening one has to survive the component still loading. assert.match(app, /function openSubItem\(sub, tabName, attempts\)/); assert.match(app, /if \(attempts > 0\) setTimeout/, 'it retries rather than clicking into an empty tab'); // Reached from the assistant, it has to navigate first and open after. assert.match(app, /localStorage\.setItem\('ped_pending_sub', sub\)/); const calculators = read('public/components/calculators.html'); assert.match(calculators, /data-calc="bili"/, 'the labels search reads are real'); // Bedside marks its emergency sections with data-em, so they are searchable too. assert.match(read('public/components/bedside.html'), /data-em="sepsis"/); assert.match(app, /\['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'\]/); }); test('the menu head reads toggle, mark, search — and account tabs are not listed twice', () => { for (const file of ['public/index.html', 'public/components/assistant.html']) { const markup = read(file); const head = markup.slice(markup.indexOf('