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 = ' ' + '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')); });