Files
stack/packages/webui/tests/s5-polish-browser.test.mjs

92 lines
10 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, S, stub } from './helpers/projects-review-fixture.mjs';
async function setup(t, route, width=390, extra='', gateRenderer=false) {
const f=await fixture(), b=await browser({machine:false});
t.after(async()=>{await b.close();await f.close()});
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
let rendererRequest;
b.on('Fetch.requestPaused',p=>{
if(gateRenderer && p.request.url===f.base+'/render.mjs') { rendererRequest=p.requestId; return; }
void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{});
});
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub+extra});
await b.viewport(width,900);await b.navigate(f.base+'/#'+route);
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,25))}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
return {b,wait,releaseRenderer:async()=>{for(let i=0;i<200&&!rendererRequest;i++)await new Promise(r=>setTimeout(r,25));assert.ok(rendererRequest,'renderer request was gated');await b.call('Fetch.continueRequest',{requestId:rendererRequest});}};
}
test('conversation and room keep a mobile gutter for long human messages',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T,390,`fixtureMessages[0].body='A very long human message '.repeat(30);fixtureRoom[0].body=fixtureMessages[0].body;`);
for(const [route,selector] of [[null,'#chat-send-form'],['/projects/rooms/'+P,'#chat-room-send-form']]) {
if(route)await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
await wait(`document.querySelector('${selector}')`);
const result=await b.evaluate(`(()=>{const e=document.querySelector('.chat-message-human .chat-body'),m=document.querySelector('.chat-message-human .chat-message-meta');const a=e.getBoundingClientRect(),b=m.getBoundingClientRect();return {body:a.right,meta:b.right,width:document.documentElement.clientWidth}})()`);
assert.ok(result.body<=result.width-10 && result.meta<=result.width-10,JSON.stringify(result));
}
});
test('a repaint while the initial conversation read is pending stays in the reading state',async t=>{
const {b,wait,releaseRenderer}=await setup(t,'/projects/threads/'+T,390,`const prior=window.fetch;let gateSession=true;window.fetch=async(u,o)=>{if(gateSession && String(u)==='/api/chat/threads/${T}/sessions'){gateSession=false;await new Promise(r=>window.releaseSessions=r)}return prior(u,o)};`,true);
await wait(`typeof window.releaseSessions==='function'`);
await releaseRenderer();
await wait(`document.querySelector('.chat-area')`);
const body=await b.evaluate(`document.querySelector('#bus-view').textContent`);
assert.doesNotMatch(body,/Thread unavailable|Conversation unavailable/);
await b.evaluate(`window.releaseSessions()`);
await wait(`document.querySelector('#chat-send-form')`);
});
test('a failed conversation read offers a retry without duplicate archive notices',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T,390,`window.reviewFail['/api/chat/threads/${T}/sessions']=true;`);
await wait(`document.querySelector('.chat-error')`);
const result=await b.evaluate(`({text:document.querySelector('#bus-view').textContent,retry:!!document.querySelector('[data-chat-retry]'),notices:[...document.querySelectorAll('#bus-view [role=status]')].filter(e=>e.textContent.includes('Archive status unavailable')).length})`);
assert.match(result.text,/Conversation unavailable/);assert.doesNotMatch(result.text,/Showing previously read chat content/);assert.equal(result.retry,true);assert.ok(result.notices<=1);
await b.evaluate(`reviewFail['/api/chat/threads/${T}/sessions']=false;document.querySelector('[data-chat-retry]').click()`);
await wait(`document.querySelector('#chat-send-form')`);
});
test('a failed initial history read can be retried without losing the draft',async t=>{
const extra=`const prior=window.fetch;let failHistory=true;window.fetch=async(u,o)=>{if(failHistory && String(u).startsWith('/api/chat/threads/${T}/messages'))return new Response('{}',{status:503});return prior(u,o)};window.clearHistoryFailure=()=>{failHistory=false};`;
const {b,wait}=await setup(t,'/projects/threads/'+T,390,extra);
await wait(`document.querySelector('.chat-error') && document.querySelector('#chat-body')`);
assert.match(await b.evaluate(`document.querySelector('.chat-log').textContent`),/Messages unavailable/);
await b.evaluate(`(()=>{const e=document.querySelector('#chat-body');e.value='A draft to keep';e.dispatchEvent(new Event('input',{bubbles:true}))})()`);
assert.equal(await b.evaluate(`!!document.querySelector('[data-chat-retry]')`),true);
await b.evaluate(`clearHistoryFailure();document.querySelector('[data-chat-retry]').click()`);
await wait(`document.querySelector('.chat-log').textContent.includes('Build a self-contained HTML Pomodoro timer')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'A draft to keep');
});
test('request identifiers are abbreviated but retain their full value',async t=>{
const id='c6946904-a20e-49c3-927f-267a67f14e81';
const {b,wait}=await setup(t,'/projects/threads/'+T,1440);
await wait(`window.MosaicProjects && document.querySelector('#chat-send-form')`);
const result=await b.evaluate(`(()=>{const host=document.createElement('div');host.innerHTML=MosaicProjects.requestIntro({request:${JSON.stringify(id)},revision:1,phase:'implementation'},'${P}');const e=host.querySelector('code');return {text:e.textContent,title:e.title}})()`);
assert.deepEqual(result,{text:id.slice(0,8),title:id});
});
test('wide routing and data tables expose horizontal scrolling inside their panel',async t=>{
const {b,wait}=await setup(t,'/projects',1440);await wait(`document.querySelector('.chat-area')`);
for(const width of [1440,390]) {
await b.viewport(width,900);
const r=await b.evaluate(`(()=>{const host=document.createElement('div');host.className='settings-content';host.style.width='300px';host.innerHTML='<div class="panel routing-panel"><div class="table-wrap"><table id="routes-table"><tr><td style="min-width:900px">wide</td></tr></table></div></div>';document.querySelector('#bus-view').append(host);const e=host.querySelector('.table-wrap');const r={overflow:getComputedStyle(e).overflowX,scroll:e.scrollWidth,client:e.clientWidth};host.remove();return r})()`);
if(width===1440)assert.ok(['auto','scroll'].includes(r.overflow) && r.scroll>r.client,JSON.stringify(r));
}
});
test('failed assistant turns explain recovery in conversation and messages',async t=>{
const extra=`fixtureMessages[1].body='The turn failed: adapter exited 127';const prior=window.fetch;window.fetch=async(u,o)=>String(u)==='/api/bus/messages'?new Response(JSON.stringify({rows:[{id:'m1',from_role:'assistant',class:'RESULT',at:'2026-11-06T10:25:00Z',body:'The turn failed: adapter exited 127'}],total:1,unread:1}),{headers:{'content-type':'application/json'}}):prior(u,o);`;
const {b,wait}=await setup(t,'/projects/threads/'+T,390,extra);await wait(`document.querySelector('.chat-message-agent .chat-body')`);
let text=await b.evaluate(`document.querySelector('.chat-message-agent').textContent`);assert.match(text,/The assistant couldn.t reply\. Try again, or check Agents\./);assert.match(text,/adapter exited 127/);
await b.evaluate(`location.hash='#/messages'`);await wait(`document.querySelector('.s1-message')`);text=await b.evaluate(`document.querySelector('#message-list').textContent`);assert.match(text,/The assistant couldn.t reply\. Try again, or check Agents\./);assert.match(text,/adapter exited 127/);
});
test('queue refusal redacts the placeholder and shows its code only once',async t=>{
const extra=`const prior=window.fetch;window.fetch=async(u,o)=>String(u)==='/api/queue'?new Response(JSON.stringify({error:'queue-refused',message:'<path> is a linked worktree; the queue runs only in the canonical checkout'}),{status:503,headers:{'content-type':'application/json'}}):prior(u,o);`;
const {b,wait}=await setup(t,'/queue',390,extra);await wait(`document.querySelector('#bus-view .banner')`);const text=await b.evaluate(`document.querySelector('#bus-view .banner').textContent`);assert.doesNotMatch(text,/<path>/);assert.equal((text.match(/queue-refused/g)||[]).length,1);
});
test('empty machine session counts do not masquerade as held role counts',async t=>{
const extra=`localStorage.setItem('mosaic-console-board',JSON.stringify({showMachine:true}));const prior=window.fetch;window.fetch=async(u,o)=>String(u)==='/api/board'?new Response(JSON.stringify({sessions:[],counts:{working:0,waiting:0,error:0,offline:0,idle:0,unknown:0},generatedAt:'2026-11-06T10:25:00Z'}),{headers:{'content-type':'application/json'}}):prior(u,o);`;
const {b,wait}=await setup(t,'/projects',390,extra);await wait(`document.querySelector('#footer')`);await b.evaluate(`document.querySelector('#show-machine').checked=true;document.querySelector('#show-machine').dispatchEvent(new Event('change',{bubbles:true}))`);await wait(`document.querySelector('#status').textContent.includes('Scanned')`);assert.doesNotMatch(await b.evaluate(`document.querySelector('#footer').textContent`),/working 0.*idle 0/);
});
test('the project list uses full width rows and hides an empty archive',async t=>{
const {b,wait}=await setup(t,'/projects',1440,`fixtureProjects=fixtureProjects.filter(p=>!p.archived);`);await wait(`document.querySelector('.project-card')`);
const r=await b.evaluate(`(()=>{const c=document.querySelector('.project-card').getBoundingClientRect(),p=document.querySelector('.chat-projects').getBoundingClientRect(),a=document.querySelector('.project-archived-filter');return {card:c.width,parent:p.width,archive:a && getComputedStyle(a).display,button:document.querySelector('.project-create summary').getBoundingClientRect().right,heading:document.querySelector('.project-list-head').getBoundingClientRect().right,buttonTop:document.querySelector('.project-create summary').getBoundingClientRect().top,headingBottom:document.querySelector('.project-list-head').getBoundingClientRect().bottom}})()`);assert.ok(r.button>r.heading-5 && r.buttonTop<r.headingBottom,JSON.stringify(r));assert.ok(r.card>=r.parent-1,JSON.stringify(r));assert.ok(!r.archive || r.archive==='none',JSON.stringify(r));
});