Files
stack/packages/webui/tests/w15-shell-browser.test.mjs

163 lines
14 KiB
JavaScript

import test, { after } from 'node:test';
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { w15Fixture, ids } from './w15-fixture.mjs';
const captures = [];
const roomReads = Object.fromEntries([ids.project, ids.empty].map(id=>[`GET /api/chat/rooms/${id}/messages?limit=100`,{status:503,body:{error:'fixture-unavailable'}}]));
after(async () => { if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'see saved test output',captures:captures.filter(Boolean)},null,2)+'\n'); });
test('shell fixture: project navigation and narrow drawer', { timeout: 90000 }, async t => {
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{...roomReads,'/api/processing':{version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false,quiescent:true,remaining:0,stopped:0}, 'GET /api/project-files?project=fixture-repository':{status:503,body:{error:'fixture-unavailable'}}, 'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md':{status:503,body:{error:'fixture-unavailable'}}}});
for (const width of [1440,390]) {
await b.viewport(width,1000); await b.navigate(f.base+'/#/projects');
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects"');
await wait('!document.querySelector("#processing-halt").disabled');
assert.equal(await b.evaluate('document.querySelector("#processing-controls").parentElement.id'), 'dashboard-processing');
assert.match(await b.evaluate('document.querySelector("#sections").textContent'), /Projects/);
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current=page]").textContent.trim()'), 'Projects');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
if (width === 1440) assert.deepEqual(await b.evaluate('[!!document.querySelector(".rail"),Math.round(document.querySelector(".tree").getBoundingClientRect().x),Math.round(document.querySelector(".tree").getBoundingClientRect().width),Math.round(document.querySelector("#main").getBoundingClientRect().x)]'), [false,0,226,226]);
captures.push(await capture('projects-list',width,'#/projects'));
if(width===390) {
await b.evaluate('document.querySelector("#nav-open").focus();document.querySelector("#nav-open").click()');
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
assert.equal(await b.evaluate('document.querySelector("#main").inert'), true);
assert.equal(await b.evaluate('document.querySelector("#processing-mobile").inert'), true);
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current]").getBoundingClientRect().top < innerHeight'), true);
captures.push(await capture('navigation-open',width,'#/projects'));
await b.key('Tab','Tab',8); assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
await b.key('Escape'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
assert.equal(await b.evaluate('document.querySelector("#main").inert'), false);
const point = await b.evaluate('(()=>{const r=document.querySelector("#nav-open").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
await b.call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
await b.key('Enter'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
} else captures.push(await capture('navigation-open',width,'#/projects'));
const sessionRoute = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute)}`);
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
captures.push(await capture('conversation-shell',width,sessionRoute));
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute.replace('#/projects/','#/chat/'))}`);
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
assert.deepEqual(await b.evaluate('window.__writes'), []);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
}
await b.viewport(400,1000); await b.navigate(f.base+'/#/projects');
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects" && !document.querySelector("#processing-halt").disabled');
captures.push(await capture('projects-list',400,'#/projects'));
});
test('display module on the real fixture server: save, failure and destination', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t);
await b.viewport(390,1000); await b.navigate(f.base+'/#/settings/display'); await wait('!!document.querySelector("[name=density]")');
await b.evaluate('document.querySelector("[name=density][value=compact]").click(); location.hash="#/tasks"'); await wait('location.hash==="#/tasks"');
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-sidebar-display-v1")).density'),'compact');
await b.navigate(f.base+'/#/settings/display'); await wait('!!document.querySelector("[name=density]")');
assert.equal(await b.evaluate('document.querySelector("[name=density]:checked").value'),'compact');
await b.evaluate('Storage.prototype.setItem=function(){throw new Error("blocked")};document.querySelector("[name=density][value=comfortable]").click()');
assert.match(await b.evaluate('document.querySelector("[data-status=density]").textContent'),/Could not save/);
await b.evaluate('location.hash="#/inbox"'); await wait('location.hash==="#/inbox"');
assert.equal(await b.evaluate('!!document.querySelector("dialog[open]")'),false); assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('canonical routes survive reload and history without writes', { timeout: 90000 }, async t => {
const { f,b,wait } = await w15Fixture(t);
const paths = [
['#/projects', '.chat-projects'],
[`#/projects/${ids.project}`, '.chat-area h1'],
[`#/projects/threads/${ids.thread}/sessions/${ids.session}`, `.chat-conversation[data-session="${ids.session}"]`],
[`#/projects/rooms/${ids.project}`, '.chat-room'],
];
for (const [route,selector] of paths) {
await b.navigate(f.base + '/' + route);
await wait(`!!document.querySelector(${JSON.stringify(selector)})`);
await b.evaluate('window.__beforeReload=true');
await b.call('Page.reload');
await wait(`!window.__beforeReload && document.readyState==='complete' && !!document.querySelector(${JSON.stringify(selector)})`);
assert.equal(await b.evaluate('location.hash'), route);
try { await wait('document.activeElement.tagName !== "BODY"'); } catch (e) { throw new Error(route + ': ' + e.message); }
await b.evaluate('location.hash="#/tasks"');
await wait('location.hash==="#/tasks"');
await b.evaluate('history.back()');
await wait(`location.hash===${JSON.stringify(route)} && !!document.querySelector(${JSON.stringify(selector)})`);
await b.evaluate('history.forward()');
await wait('location.hash==="#/tasks"');
}
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('drawer preserves conversation draft, displays known context and contains focus', { timeout: 90000 }, async t => {
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{...roomReads,'GET /api/project-files?project=fixture-repository':{status:503,body:{error:'fixture-unavailable'}}, 'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md':{status:503,body:{error:'fixture-unavailable'}}}});
for (const width of [1440,390]) {
await b.viewport(width,1000);
const route = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
await b.navigate(f.base + '/' + route);
await wait('!!document.querySelector("#chat-body") && !!document.querySelector("#project-context .context-conversation")');
await b.evaluate('document.querySelector("#chat-body").value="Unsent fixture draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
assert.match(await b.evaluate('document.querySelector("#project-context").textContent'), /Active project.*Project conversation/s);
if (width===390) {
await b.evaluate('document.querySelector("#nav-open").click()');
const ax = await b.call('Accessibility.getFullAXTree');
assert.ok(ax.nodes.some(n => n.role?.value==='navigation' && n.name?.value==='Console'));
assert.ok(ax.nodes.some(n => n.name?.value==='Projects'));
await b.key('Tab','Tab',8);
assert.equal(await b.evaluate('document.activeElement.closest("#console-nav") !== null'), true);
await b.key('Escape');
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
}
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'Unsent fixture draft');
captures.push(await capture('navigation-closed-with-draft',width,route));
await b.evaluate('document.querySelector("#refresh").focus();document.querySelector("#refresh").click()');
await wait('document.querySelector("#chat-body")?.value==="Unsent fixture draft"');
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
}
assert.deepEqual(await b.evaluate('window.__writes'), []);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
});
test('display screenshots and zoom, dark mode, hostile labels', { timeout: 90000 }, async t => {
const hostile = '<img src=x onerror=alert(1)> ' + 'Long project '.repeat(35) + '\u202eevil';
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/chat/projects':{rows:[{id:ids.project,name:hostile,repo:ids.repository}]}}});
for (const width of [1440,390]) {
await b.viewport(width,1000); await b.navigate(f.base+'/#/settings/appearance'); await wait('!!document.querySelector("[name=density]")');
assert.ok(await b.evaluate('document.querySelector(".settings-segments span").getBoundingClientRect().height >= 44'));
await b.evaluate('document.querySelector(`[name=density][value=${document.querySelector("[name=density]:checked").value === "compact" ? "comfortable" : "compact"}]`).click()');
await wait('document.querySelector("[data-status=density]").textContent.includes("Saved")'); captures.push(await capture('display-saved',width,'#/settings/appearance'));
await b.evaluate('window.__setItem=Storage.prototype.setItem;Storage.prototype.setItem=function(){throw new Error("blocked")};document.querySelector(`[name=density][value=${document.querySelector("[name=density]:checked").value === "compact" ? "comfortable" : "compact"}]`).click()');
assert.match(await b.evaluate('document.querySelector("[data-status=density]").textContent'),/Could not save/); captures.push(await capture('display-save-failed',width,'#/settings/appearance'));
await b.evaluate('Storage.prototype.setItem=window.__setItem;location.hash="#/projects"'); await wait('!!document.querySelector(".chat-projects")');
assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'),false);
await b.evaluate('document.documentElement.dataset.mode="dark"; document.documentElement.style.zoom="2"');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).colorScheme'),'dark');
}
});
test('optional project tree mount, storage refusal and sign-out clear transient choices', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:`window.MosaicProjectTree={mount({container,path}){container.textContent='Stub tree: '+path}};`});
await b.navigate(f.base+'/#/settings/appearance'); await wait('!!document.querySelector("[name=density]")');
assert.match(await b.evaluate('document.querySelector("#project-tree").textContent'), /Stub tree: \/settings\/appearance/);
await b.evaluate('document.querySelector("[name=density][value=compact]").click(); document.dispatchEvent(new Event("mosaic-signed-out")); location.hash="#/projects"');
await wait('location.hash==="#/projects"'); assert.equal(await b.evaluate('!!document.querySelector("dialog[open]")'), false);
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-sidebar-display-v1")).density'), 'compact');
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('owner display asset is not exposed through the limited user route', async () => {
const {userRoute} = await import('../src/serve.mjs');
assert.equal(userRoute('/display.js','GET'), false);
assert.equal(userRoute('/display.js','HEAD'), false);
});
test('shared fixture refuses unknown APIs and permits only explicit scenario writes', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t, {scenario:{'POST /api/fixture/allowed':{status:202,body:{accepted:true}}}});
await b.navigate(f.base+'/#/projects'); await wait('!!document.querySelector(".chat-projects")');
const result = await b.evaluate(`(async()=>{const a=await fetch('/api/unknown'),b=await fetch('/api/fixture/unknown',{method:'POST'}),c=await fetch('/api/fixture/allowed',{method:'POST'}),d=await fetch('https://example.invalid/');return [a.status,b.status,c.status,d.status]})()`);
assert.deepEqual(result,[403,403,202,403]);
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.path)'), ['/api/fixture/unknown','/api/fixture/allowed']);
assert.ok((await b.evaluate('window.__unexpected')).includes('POST /api/fixture/unknown'));
});