const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { JSDOM, requestInterceptor, VirtualConsole } = require('jsdom'); const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); const css = read('public/css/assistant.css'); function loaderFixture() { let requested; const pending = []; const requests = []; const dom = new JSDOM('
', { url: 'https://example.test/', runScripts: 'outside-only', virtualConsole: new VirtualConsole(), resources: { interceptors: [requestInterceptor(request => { assert.equal(request.url, 'https://example.test/css/assistant.css?v=fixture-build'); return new Promise(resolve => { pending.push(resolve); if (requested) requested(); }); })] } }); dom.window.fetch = async url => { requests.push(url); if (url === '/components/assistant.html?v=fixture-build') return new Response(read('public/components/assistant.html')); assert.equal(url, '/api/models'); return new Response(JSON.stringify({ models: [] })); }; dom.window.eval(read('public/js/app.js')); return { dom, requests, async stylesheetRequested() { if (!pending.length) await new Promise(resolve => { requested = resolve; }); requested = null; }, release(status = 200) { pending.shift()(new Response(css, { status, headers: { 'Content-Type': 'text/css' } })); } }; } function nextTab(dom) { return new Promise(resolve => dom.window.document.addEventListener('tabChanged', resolve, { once: true })); } test('actual component loader waits for versioned CSS before first initialization and cached reopen', async () => { const fixture = loaderFixture(); const { dom } = fixture; const tab = dom.window.document.getElementById('assistant-tab'); let events = 0; dom.window.document.addEventListener('tabChanged', () => { events++; }); await fixture.stylesheetRequested(); dom.window.activateTab('assistant'); assert.equal(events, 0); assert.equal(tab.dataset.loaded, undefined); assert.equal(tab.lastElementChild.tagName, 'LINK', 'Stylesheet stays at the previous style block position'); assert.equal(tab.querySelector('style'), null); assert.equal(tab.getAttribute('aria-busy'), 'true'); assert.match(tab.querySelector('[role="status"]').textContent, /Loading/); assert.ok(tab.querySelector('#assistant-input').closest('[inert]'), 'Native inert prevents entering a draft before handlers exist'); const ready = nextTab(dom); fixture.release(); await ready; assert.equal(tab.dataset.loaded, '1'); assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null); assert.equal(tab.hasAttribute('aria-busy'), false); assert.equal(dom.window.getComputedStyle(tab.querySelector('.assistant-messages')).maxHeight, '65vh'); const bubble = dom.window.document.createElement('div'); bubble.className = 'assistant-msg user'; bubble.innerHTML = '
preserve\nline
'; tab.appendChild(bubble); assert.equal(dom.window.getComputedStyle(bubble.firstChild).whiteSpace, 'pre-wrap'); const unchangedLink = tab.querySelector('link'); const reactivated = nextTab(dom); dom.window.activateTab('assistant'); await reactivated; assert.equal(tab.querySelector('link'), unchangedLink, 'Normal reopen keeps the loaded CSS'); // Exercise the loader's HTML-cache path too, not only the already-loaded fast path. tab.replaceChildren(); delete tab.dataset.loaded; dom.window.activateTab('assistant'); await fixture.stylesheetRequested(); assert.equal(events, 2); assert.equal(tab.dataset.loaded, undefined); const reopened = nextTab(dom); fixture.release(); await reopened; assert.equal(tab.dataset.loaded, '1'); assert.equal(dom.window.getComputedStyle(tab.querySelector('.assistant-messages')).maxHeight, '65vh'); assert.equal(fixture.requests.filter(url => url.includes('/components/')).length, 1); assert.ok(css.indexOf('@media (max-width: 960px)') < css.indexOf('@media (max-width: 640px)')); dom.window.close(); }); test('stylesheet failure never marks an unstyled assistant ready; reactivation retries', async () => { const fixture = loaderFixture(); const { dom } = fixture; const tab = dom.window.document.getElementById('assistant-tab'); await fixture.stylesheetRequested(); dom.window.activateTab('assistant'); assert.ok(tab.querySelector('#assistant-input').closest('[inert]')); let readyEvents = 0; dom.window.document.addEventListener('tabChanged', () => { readyEvents++; }); const failed = new Promise(resolve => { const observer = new dom.window.MutationObserver(() => { if (tab.querySelector('[role="alert"]')) { observer.disconnect(); resolve(); } }); observer.observe(tab, { childList: true }); }); fixture.release(503); await failed; assert.equal(readyEvents, 0, 'Failure must not initialize an absent component'); assert.equal(tab.hasAttribute('aria-busy'), false); assert.equal(tab.dataset.loaded, undefined); assert.equal(tab.querySelector('#assistant-form'), null); assert.match(tab.textContent, /Failed to load/); dom.window.activateTab('assistant'); await fixture.stylesheetRequested(); const recovered = nextTab(dom); fixture.release(); await recovered; assert.equal(tab.dataset.loaded, '1'); assert.ok(tab.querySelector('#assistant-form')); assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null); dom.window.close(); }); test('out-of-order CSS completion does not initialize an inactive tab; returning binds before enabling', async () => { const fixture = loaderFixture(); const { dom } = fixture; const tab = dom.window.document.getElementById('assistant-tab'); const events = []; dom.window.document.addEventListener('tabChanged', event => { events.push(event.detail.tab); if (event.detail.tab === 'assistant') { assert.ok(tab.querySelector('#assistant-input').closest('[inert]'), 'Controls remain inert while initialization listeners run'); } }); await fixture.stylesheetRequested(); dom.window.activateTab('assistant'); const notesReady = nextTab(dom); dom.window.activateTab('notes'); await notesReady; const cssReady = new Promise(resolve => tab.querySelector('link').addEventListener('load', resolve, { once: true })); fixture.release(); await cssReady; await new Promise(resolve => setImmediate(resolve)); assert.deepEqual(events, ['notes']); assert.equal(tab.dataset.loaded, '1'); assert.equal(tab.classList.contains('active'), false); assert.ok(tab.querySelector('#assistant-input').closest('[inert]')); const assistantReady = nextTab(dom); dom.window.activateTab('assistant'); await assistantReady; assert.deepEqual(events, ['notes', 'assistant']); assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null); assert.equal(tab.querySelector('[role="status"]'), null); dom.window.close(); }); test('the empty state is composed, not a stack of competing blocks', () => { 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.match(html, /assistant-empty-badge/, 'one status pill under the title'); assert.doesNotMatch(html, /<\/i>\s*

/, 'no oversized icon above the title'); const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8'); // The graph-paper ground reads as a surface only while the transcript is // empty; once there are messages it would fight the text. assert.match(css, /\.assistant-messages:has\(\.assistant-msg\) \{ background-image:none; \}/); assert.match(css, /\.assistant-kbd/, 'New chat advertises its shortcut'); assert.match(css, /\.assistant-chats-header\[aria-expanded="false"\] \.assistant-chats-caret/, 'the chats group collapses'); }); test('the advertised New chat shortcut actually works, and only inside the assistant', () => { const fs = require('node:fs'); const path = require('node:path'); const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8'); const handler = js.slice(js.indexOf('// Ctrl+Shift+O starts a new chat'), js.indexOf('var closeDrawer')); assert.match(handler, /event\.ctrlKey && event\.shiftKey|!event\.ctrlKey \|\| !event\.shiftKey/); assert.match(handler, /toLowerCase\(\) !== 'o'/); // Must not hijack Ctrl+Shift+O for the rest of the app. assert.match(handler, /classList\.contains\('assistant-workspace'\)/); assert.match(handler, /btn-assistant-clear/); }); test('the admin panel stays usable on a phone', () => { const fs = require('node:fs'); const path = require('node:path'); const root = path.join(__dirname, '..'); const css = fs.readFileSync(path.join(root, 'public/css/styles.css'), 'utf8'); // Admin rows are a 3-column grid on desktop; on a phone they must stack or the // label column squeezes every control into an unusable sliver. const mobile = css.slice(css.indexOf('@media (max-width:640px) {', css.indexOf('.admin-row {'))); assert.match(mobile, /\.admin-row \{ grid-template-columns:1fr; \}/, 'rows stack'); assert.match(mobile, /\.admin-control \{ max-width:none; \}/, 'controls use the full width'); // Every block added inside a row must be able to shrink, or it forces the // grid cell wider than the screen and the page scrolls sideways. const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'); const rowChildren = html.split('\n').filter(l => l.includes('style="flex:1;display:flex')); assert.ok(rowChildren.length > 0, 'found the flex blocks inside admin rows'); for (const line of rowChildren) { assert.match(line, /min-width:0/, 'a flex child inside an admin row must be shrinkable: ' + line.trim().slice(0, 60)); } });