152 lines
7.1 KiB
JavaScript
152 lines
7.1 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();
|
|
});
|