Files
stack/packages/webui/tests/settings-review-browser.test.mjs

56 lines
21 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {w15Fixture} from './w15-fixture.mjs';
const rev='a'.repeat(64), long='a'.repeat(64);
const instances={revision:rev,launch:true,instances:{pm:{definition:'coder',launched:true,kept:false,running:['run-1'],protected:false,endProtected:false},[long]:{definition:'coder',launched:true,kept:false,running:[long],protected:false,endProtected:false}},definitions:{coder:{accepted:true,services:[]}},noHire:[],retired:[]};
const launch={revision:rev,models:{opus:{provider:'anthropic',model:'opus'}},limits:{},usage:{},assistants:{assistant:{definition:'assistant',vars:{},tracker:null}}};
const routes={revision:rev,harnesses:['claude-code','codex'],models:['opus'],levels:{'claude-code':['high','xhigh'],codex:['high','xhigh']},instances:{pm:{harness:'claude-code',model:'opus',thinking:'high'}}};
const scenario={'/api/config':{devOwner:true,send:true,decide:true,edit:true},'/api/settings':{launch,notifier:{binding:null}},'/api/bus/instances':{rows:[instances]},'/api/bus/routes':{rows:[routes]},'/api/bus/names':{rows:[{instance:'assistant',name:'Piper'}]},'/api/bus/limits':{rows:[]},'/api/bus/usage':{rows:[]},'/api/proxy/accounts':{accounts:[{accountId:'fixture',displayName:'Fixture',provider:'claude',status:'ready',priority:100}]},'/api/proxy/keys':{keys:[]}};
async function page(t,section,width=1440,options={}) {
const x=await w15Fixture(t,{scenario}); const {b,f,wait}=x;
if(options.poll)await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{const original=setTimeout;window.setTimeout=(fn,ms,...args)=>{if(ms===10000)window.__pollRefresh=fn;return original(fn,ms,...args)}})()`});
await b.viewport(width,width===390?900:1000);await b.navigate(f.base+'/#/settings/'+section);
await wait(`document.querySelector('#settings-page-title')?.textContent===${JSON.stringify(({agents:'Agents',routing:'Routing',limits:'Launch limits',assistant:'Assistant',appearance:'Appearance','model-proxy':'Model proxy'})[section])}`);
if(section==='agents')await wait('!!document.querySelector(".agent-more")');
if(section==='routing')await wait('!!document.querySelector("[data-route]")');
if(section==='model-proxy')await wait('!!document.querySelector("#proxy-accounts form")');
return x;
}
async function pointer(b,sel,xpart=.5){const pt=await b.evaluate(`(()=>{let e=document.querySelector(${JSON.stringify(sel)});e.scrollIntoView({block:'center'});let r=e.getBoundingClientRect();return {x:r.left+r.width*${xpart},y:r.top+r.height/2}})()`);for(const type of ['mousePressed','mouseReleased'])await b.call('Input.dispatchMouseEvent',{type,...pt,button:'left',clickCount:1});}
async function input(b,sel,value){await b.evaluate(`(()=>{let e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.focus()})()`);}
const opts={timeout:90000};
test('F1 More Edit opens and focuses editor through keyboard and pointer',opts,async t=>{const {b,wait}=await page(t,'agents');await b.evaluate('document.querySelector(".agent-more").focus()');await b.key('ArrowDown');await b.key('Enter');await wait('!!document.querySelector("#instance-change-form")');assert.equal(await b.evaluate('document.activeElement.closest("#instance-change-form")!==null'),true);await b.evaluate('location.hash="#/settings/appearance"');await wait('!!document.querySelector(".settings-appearance")');await b.evaluate('location.hash="#/settings/agents"');await wait('!!document.querySelector(".agent-more")');await pointer(b,'.agent-more');await pointer(b,'[data-agent-edit]');await wait('!!document.querySelector("#instance-change-form")');});
test('F2 pointer on hiring track and thumb and keyboard all send',opts,async t=>{const {b,wait}=await page(t,'agents');await pointer(b,'.switch-track',.5);await wait('__writes.length===1');await wait('document.querySelector("#agents-receipt")?.textContent.length>0');assert.equal(await b.evaluate('__writes[0].path'),'/api/veto');await b.evaluate('window.__previousSwitch=document.querySelector(".hiring-grid [role=switch]")');await pointer(b,'.switch-track',.15);await wait('__writes.length===2');await wait('document.querySelector(".hiring-grid [role=switch]")!==window.__previousSwitch && document.querySelector("#agents-receipt")?.textContent.length>0');await b.evaluate('document.querySelector(".hiring-grid [role=switch]").focus()');await b.key(' ');await wait('__writes.length===3');});
test('F3 phone shows invalid, refusal and unknown receipts while draft remains',opts,async t=>{const {b,wait}=await page(t,'limits',390);await input(b,'#launch-settings-form input[type=number]','-1');await b.key('Enter');await wait('!!document.querySelector("[aria-invalid]")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").getBoundingClientRect().height>0'),true);await input(b,'#launch-settings-form input[type=number]','2');await b.evaluate(`__scenario['POST /api/settings']={status:409,body:{message:'Fixture conflict',sent:false}}`);await b.key('Enter');await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Fixture conflict")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").getBoundingClientRect().height>0'),true);await b.evaluate(`__scenario['POST /api/settings']={status:500,body:{sent:'unknown'}}`);await wait('document.querySelector("#launch-settings-form button[type=submit]")?.disabled===false');await b.evaluate("document.querySelector('#launch-settings-form').requestSubmit()");await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Outcome unknown")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").getBoundingClientRect().height>0'),true);});
test('F4 polling preserves Vars and fallback expansion, focus and caret',opts,async t=>{const {b,wait}=await page(t,'agents',1440,{poll:true});await b.evaluate('document.querySelector("#instance-add-form summary").click()');await input(b,'#instance-add-form textarea','{"note":"draft"}');await b.evaluate('document.activeElement.setSelectionRange(4,4);window.__old=document.activeElement;window.__pollRefresh()');await wait('document.querySelector("#instance-add-form textarea")!==window.__old');assert.deepEqual(await b.evaluate('({open:document.querySelector("#instance-add-form details").open,focus:document.activeElement===document.querySelector("#instance-add-form textarea"),caret:document.activeElement.selectionStart})'),{open:true,focus:true,caret:4});await b.evaluate('location.hash="#/settings/routing"');await wait('!!document.querySelector(".fallback-toggle")');await b.viewport(390,900);await pointer(b,'.fallback-toggle');await b.evaluate('document.querySelector(".fallback-cell select").focus();window.__old=document.activeElement;window.__pollRefresh()');await wait('document.querySelector(".fallback-cell select")!==window.__old');assert.equal(await b.evaluate('document.querySelector(".route-row").classList.contains("fallback-open")&&document.activeElement===document.querySelector(".fallback-cell select")'),true);});
test('F5 Enter in seat name saves only name despite settings draft',opts,async t=>{const {b,wait}=await page(t,'assistant',390);await input(b,'[data-assistant-field=definition]','changed');await input(b,'[data-assistant-field=name]','Wren');await b.key('Enter');await wait('__writes.length>0');assert.equal(await b.evaluate('__writes[0].path'),'/api/name');assert.deepEqual(await b.evaluate('JSON.parse(__writes[0].body)'),{instance:'assistant',name:'Wren'});});
test('F6 add agent labels left aligned with launch on own row',opts,async t=>{const {b}=await page(t,'agents');const geometry=await b.evaluate(`(()=>{let f=document.querySelector('#instance-add-form'),l=[...f.querySelectorAll('.filters>label')],c=l.map(e=>e.querySelector('input,select').getBoundingClientRect()),r=l.map(e=>e.getBoundingClientRect());return {left:l.slice(0,2).every(e=>getComputedStyle(e).alignItems==='flex-start'),separate:r[2].top>=Math.max(c[0].bottom,c[1].bottom),gap:f.querySelector('button[type=submit]').getBoundingClientRect().top-f.querySelector('details').getBoundingClientRect().bottom}})()`);assert.equal(geometry.left,true);assert.equal(geometry.separate,true);assert.ok(geometry.gap>=12);});
test('F7 maximum length agent values wrap at 390',opts,async t=>{const {b}=await page(t,'agents',390);assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'),true);});
test('F8 root proxy route mounts its settings section',opts,async t=>{const {b}=await page(t,'model-proxy');assert.equal(await b.evaluate('location.pathname'),'/');assert.equal(await b.evaluate('!!document.querySelector("#proxy-accounts")'),true);});
test('F9 add agent saving and refusal remain local and visible',opts,async t=>{const {b,wait}=await page(t,'agents');await pointer(b,'.agent-more');await pointer(b,'[data-agent-edit]');await wait('!!document.querySelector("#instance-change-form")');await input(b,'#instance-add-form [data-instance-field=instance]','new-coder');await b.evaluate(`document.querySelector('#instance-add-form select').value='coder';window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o);document.querySelector('#instance-add-form').requestSubmit()`);await wait('!!window.__answer && !!document.querySelector("#instance-add-form .settings-spinner")');assert.equal(await b.evaluate('!!document.querySelector("#instance-add-form .settings-spinner")'),true);assert.equal(await b.evaluate('document.querySelector("#instance-change-form select").disabled'),false);await b.evaluate('__answer(409,{message:"Fixture conflict",sent:false})');await wait('document.querySelector("#instances-receipt")?.textContent.includes("Fixture conflict")');assert.equal(await b.evaluate('!!document.querySelector("#instance-add-form .banner.err")'),true);assert.equal(await b.evaluate('document.querySelector("#instance-add-form .banner.err").getBoundingClientRect().bottom<=document.querySelector("#instance-add-form button[type=submit]").getBoundingClientRect().top'),true);});
test('F10 routing widths, segmented focus, conditional proxy link and priority style',opts,async t=>{const {b,wait}=await page(t,'routing');assert.equal(await b.evaluate('[...document.querySelectorAll("#routes-table select")].every(e=>e.getBoundingClientRect().width>=132)'),true);await b.evaluate('location.hash="#/settings/appearance"');await wait('!!document.querySelector(".settings-segments")');await b.evaluate('document.querySelector("[name=theme]:checked").focus()');await b.key('ArrowLeft');assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".settings-segments")).outlineStyle!=="none"'),true);await pointer(b,'#settings-nav a[href="#/settings/model-proxy"]');await wait('!!document.querySelector("#proxy-accounts form")');await b.evaluate(`window.open=()=>null;__scenario['POST /api/proxy/login/start']={status:200,body:{url:'https://fixture.invalid/authorize',state:'fixture-state'}}`);await pointer(b,'#proxy-login-start button[type=submit]');await wait('document.querySelector("#proxy-authorize")?.hidden===false');assert.equal(await b.evaluate(`document.querySelector('#proxy-authorize').getBoundingClientRect().height>=44`),true);assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#proxy-accounts button")).backgroundColor===getComputedStyle(document.querySelector("#proxy-refresh")).backgroundColor'),true);});
test('F11 Vars fields have labels and mobile agent values identify columns',opts,async t=>{const {b,wait}=await page(t,'agents',390);assert.equal(await b.evaluate('document.querySelector("#instance-add-form textarea").labels.length>0'),true);assert.match(await b.evaluate('document.querySelector("#agents-table tbody tr").innerText'),/Definition/);assert.equal(await b.evaluate('document.querySelector("#agents-table tbody td").getAttribute("headers")'), 'agent-col-definition');await b.viewport(1440,1000);await pointer(b,'.agent-more');await pointer(b,'[data-agent-edit]');await wait('!!document.querySelector("#instance-change-form")');assert.equal(await b.evaluate('document.querySelector("#instance-change-form textarea").labels.length>0'),true);});
test('F12 collapsed section announces current draft and clears after discard',opts,async t=>{const {b,wait}=await page(t,'routing',390);await b.evaluate(`(()=>{let e=[...document.querySelectorAll('[data-route]')].find(e=>JSON.parse(e.dataset.route)[1]==='thinking');e.value='xhigh';e.dispatchEvent(new Event('change',{bubbles:true}))})()`);assert.equal(await b.evaluate('!!document.querySelector("#settings-disclosure .settings-dot")'),true);assert.match(await b.evaluate('document.querySelector("#settings-disclosure").textContent'),/unsaved changes/);await pointer(b,'#routes-reload');await wait('!document.querySelector("#settings-nav .settings-dot")');assert.equal(await b.evaluate('!!document.querySelector("#settings-disclosure .settings-dot")'),false);});
test('F13 each browser save receipt expires independently',opts,async t=>{const {b}=await page(t,'appearance');await b.evaluate('document.querySelector("[name=theme][value=dim]").focus();document.querySelector("[name=theme][value=dim]").click();document.querySelector("[name=palette][value=grove]").focus();document.querySelector("[name=palette][value=grove]").click();document.querySelector("[name=density][value=compact]").focus();document.querySelector("[name=density][value=compact]").click()');await b.evaluate('new Promise(r=>setTimeout(r,4200))');assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".settings-browser-status")].map(e=>e.textContent)'),['','','']);});
test('F14 settings subtitle is 16px and fallback option is None',opts,async t=>{const {b,wait}=await page(t,'appearance');assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#bus-view .page-head p")).fontSize'),'16px');await b.evaluate('location.hash="#/settings/routing"');await wait('!!document.querySelector(".fallback-cell select")');assert.equal(await b.evaluate('document.querySelector(".fallback-cell select option").textContent'),'None');});
test('F9 assistant saving shows spinner and local refusal',opts,async t=>{const {b,wait}=await page(t,'assistant');await input(b,'[data-assistant-field=definition]','changed');await b.evaluate(`window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o);document.querySelector('.assistant-form').requestSubmit()`);await wait('!!window.__answer && !!document.querySelector(".assistant-form .settings-spinner")');assert.equal(await b.evaluate('!!document.querySelector(".assistant-form .settings-spinner")'),true);assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=name]").disabled'),false);await b.evaluate('__answer(409,{message:"Fixture conflict",sent:false})');await wait('document.querySelector("[data-assistant-receipt]")?.textContent.includes("Fixture conflict")');assert.equal(await b.evaluate('document.querySelector("[data-assistant-receipt]").classList.contains("err")'),true);});
test('F9 proxy priority disables input and shows saving',opts,async t=>{const {b,wait}=await page(t,'model-proxy');await b.evaluate(`window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o);document.querySelector('#proxy-accounts form').requestSubmit()`);await wait('!!window.__answer');assert.equal(await b.evaluate('document.querySelector("#proxy-accounts input").disabled'),true);assert.equal(await b.evaluate('!!document.querySelector("#proxy-accounts .settings-spinner")'),true);assert.equal(await b.evaluate('document.querySelector("#proxy-apikey [name=key]").matches(":disabled")'),false);await b.evaluate('__answer(409,{error:"fixture"})');await wait('!!document.querySelector("#proxy-accounts form .banner.err")');await b.evaluate(`window.__answer=null;let f=document.querySelector('#proxy-apikey');f.elements.key.value='fixture-key';f.requestSubmit()`);await wait('!!window.__answer');assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").disabled'),true);assert.equal(await b.evaluate('!!document.querySelector("#proxy-apikey .settings-spinner")'),true);await b.evaluate('__answer(409,{error:"fixture"})');await wait('!!document.querySelector("#proxy-apikey-message.banner.err")');assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").value'), '');});
test('F3 routing refusal remains visible and announced in the phone draft bar',opts,async t=>{const {b,wait}=await page(t,'routing',390);await b.evaluate(`__scenario['POST /api/routes']={status:409,body:{message:'Fixture conflict',sent:false}};[...document.querySelectorAll('[data-route]')].find(e=>JSON.parse(e.dataset.route)[1]==='thinking').focus()`);await b.key('ArrowDown');await wait('!!document.querySelector("#settings-disclosure .settings-dot")');await pointer(b,'#routes-form button[type=submit]');await wait('document.querySelector("#routes-receipt")?.textContent.includes("Fixture conflict")');assert.deepEqual(await b.evaluate(`(()=>{const e=document.querySelector('#routes-receipt'),r=e.getBoundingClientRect();return {visible:r.width>0&&r.height>0,role:e.getAttribute('role'),banner:e.classList.contains('err')}})()`),{visible:true,role:'status',banner:true});});
test('F9 simultaneous assistant card refusals stay with their own drafts',opts,async t=>{const {b,wait}=await page(t,'assistant');await b.evaluate(`window.__answers={};window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answers[u]=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o)`);await input(b,'[data-assistant-field=definition]','changed');await b.evaluate('document.querySelector(".assistant-form").requestSubmit()');await wait('!!window.__answers["/api/settings"] && !!document.querySelector(".assistant-form .settings-spinner")');await input(b,'[data-assistant-field=name]','Wren');await b.key('Enter');await wait('!!window.__answers["/api/name"]');await b.evaluate(`__answers['/api/name'](409,{message:'Name refused',sent:false})`);await wait('document.querySelector("[data-assistant-kind=name]")?.textContent.includes("Name refused")');await b.evaluate(`__answers['/api/settings'](409,{message:'Settings refused',sent:false})`);await wait('document.querySelector("[data-assistant-kind=settings]")?.textContent.includes("Settings refused")');assert.match(await b.evaluate('document.querySelector("[data-assistant-kind=name]").textContent'),/Name refused/);assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=name]").value'),'Wren');assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=definition]").value'),'changed');});
test('S5 routing remains inside its panel and reaches the last fallback control',opts,async t=>{
const {b}=await page(t,'routing',1440);
const r=await b.evaluate(`(()=>{const p=document.querySelector('.routing-panel').getBoundingClientRect(),w=document.querySelector('#routes-table').parentElement,wr=w.getBoundingClientRect(),last=document.querySelector('#routes-table tr:last-child td:last-child select').getBoundingClientRect();return {panel:p.right,wrap:wr.right,last:last.right,viewport:document.documentElement.clientWidth,scroll:w.scrollWidth,client:w.clientWidth,overflow:getComputedStyle(w).overflowX}})()`);
assert.ok(r.panel<=r.viewport && r.wrap<=r.panel && (r.last<=r.wrap || r.scroll>r.client && r.overflow==='scroll'),JSON.stringify(r));
});
test('S5 mobile token use, proxy and held roles expose their clipped columns',opts,async t=>{
const {b,wait}=await page(t,'model-proxy',390);
await b.evaluate(`__scenario['/api/bus/usage']={rows:[{total:{input:1,output:2,cacheWrite:3,cacheRead:4,unknownTurns:5}}]};__scenario['/api/bus/agents']={rows:[{role:'assistant',state:'idle',attention:'none',holder:'fixture',at:'2026-11-06T10:25:00Z'}]}`);
for(const [route,selector] of [['/settings/model-proxy','#proxy-accounts'],['/settings/tokens','#token-usage-table'],['/agents','.s1-agents']]){
if(route!=='/settings/model-proxy')await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
await wait(`document.querySelector(${JSON.stringify(selector)})`);
const r=await b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)}).closest('.table-wrap');return {overflow:getComputedStyle(e).overflowX,scroll:e.scrollWidth,client:e.clientWidth,right:e.getBoundingClientRect().right,viewport:document.documentElement.clientWidth}})()`);
assert.ok(r.right<=r.viewport && r.scroll>r.client && r.overflow==='scroll',route+' '+JSON.stringify(r));
}
});