pediatric-ai-scribe-v3/test/assistant-component-css.test.js
Daniel 87c3df67ac fix: restore the ListBucket grant the stale operator image dropped
Image generation was failing with "Generated image storage or migration
unavailable". The real cause was a 403 the catch block was swallowing: the
generated-images-app MinIO policy had lost its s3:ListBucket statement, so the
storage preflight's HeadBucket was denied while writes still worked.

Re-running the storage bootstrap tonight did it. The operator image carries its
own baked-in /opt/storage/check.py, and that copy is older than the file beside
the compose: the host copy grants ListBucket with a comment saying the preflight
needs it, the image copy does not, and running the image overwrote the good
policy. The image's copy also still resolves the store as "clinical-milvus",
from before that service was renamed.

The policy is restored, and the compose now mounts the host check.py (and
bootstrap_basic.py) over the image's, so what runs is what can be read and
reviewed here. Verified: bootstrap re-runs are idempotent again and storage
readiness passes after one.

Also styles the model choice as a real control — a matching chevron, hover and
focus states in the app accent — instead of a bare form element, in both the
composer pill and the Create image popup.

Adds a test that admin rows stack on a phone and that every flex block added
inside one can shrink, so the panel cannot start scrolling sideways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
2026-09-10 02:44:02 +02:00

201 lines
10 KiB
JavaScript

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('<script src="/js/app.js?v=fixture-build"></script><button class="tab-btn" data-tab="assistant"></button><section id="assistant-tab" class="tab-content" data-component="assistant"></section><button class="tab-btn" data-tab="notes"></button><section id="notes-tab" class="tab-content"></section>', {
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 = '<div class="assistant-bubble"> preserve\nline</div>';
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 class="fas fa-book-medical"><\/i>\s*<h3>/, '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));
}
});