Files
stack/packages/webui/tests/routes-browser.test.mjs

267 lines
27 KiB
JavaScript

// Row 65 (#1558, REQ-PLUG-2, mos-0840): Settings shows one routing row per instance with its six
// cells, and a save posts the changed rows with the read's revision. The page's POST is held by a
// fetch stub and sent on by the test, with the Console's own headers, to a second server on the
// same real broker; the browser can't set Origin or Host itself. So a save reaches the business
// file through routes.set, and each refusal comes from the bus with the draft kept.
import test from 'node:test';
import assert from 'node:assert/strict';
import {request} from 'node:http';
import {readFileSync} from 'node:fs';
import {join} from 'node:path';
import {browser} from './browser.mjs';
import {startServer} from '../src/serve.mjs';
import {BusReadError} from '../src/bus.mjs';
import {close} from './fixture.mjs';
import {BusError} from '../../bus/src/broker.mjs';
import {startBroker} from '../../bus/src/runtime.mjs';
import {systemFor} from '../../organization/tests/helpers.mjs';
import {fixture,tmp} from '../../cli/tests/helpers.mjs';
import {createSsoSession} from './helpers/sso-session.mjs';
const KEYS=['harness','model','thinking','fallback.harness','fallback.model','fallback.thinking'];
const post=(port,headers,data)=>new Promise((resolve,reject)=>{
const req=request({hostname:'127.0.0.1',port,path:'/api/routes',method:'POST',headers},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,body:JSON.parse(text)}));});
req.on('error',reject);req.end(JSON.stringify(data));
});
test('row 65 Settings: the routing table lists every instance, saves a harness, model, thinking and fallback to the file, and keeps the draft on each refusal',{timeout:120000},async t=>{
const root=tmp(t,'webui-routes-browser-');
// The reviewer starts with a fallback, so the table draws a set one as well as an empty one.
const f=fixture(root,'acme',doc=>{doc.roles.reviewer.vars={...doc.roles.reviewer.vars,'fallback.harness':'pi','fallback.model':'glm-5.3-flash','fallback.thinking':'low'};return doc;});
const dir=join(root,'config'),file=join(dir,'businesses','acme.json');
const host=await startBroker({
dataRoot:f.dataRoot,
businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},
organization:{dir,rolesDir:f.roles,system:systemFor(root)},
});
t.after(()=>host.close());
const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
const call=async(verb,args)=>{
try{return structuredClone(host.broker.request(human,{verb,args}));}catch(e){if(e instanceof BusError)throw new BusReadError(e.code);throw e;}
};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
const lines=[];
const auth=await createSsoSession(t,{host:'console.example'});
const writer=await startServer({port:0,sso:auth.sso,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)});
const session=await auth.signIn(writer.address().port),headers=auth.writeHeaders(session);
const b=await browser();
try{
// The stub holds each POST until the test answers it with what the write server said.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Promise(resolve=>{window.answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}));});}return original(url,options);};`});
const port=web.address().port;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const receipt=()=>b.evaluate('document.querySelector("#routes-receipt").textContent');
const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`;
// A harness change redraws the form, so each cell is looked up again, and waited for until the
// redraw offers the value.
const set=async(name,key,value)=>{assert.equal(await b.evaluate(`(async()=>{for(let n=0;n<100;n++){const e=${cell(name,key)};if(e&&[...e.options].some(o=>o.value===${JSON.stringify(value)})){e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;}await new Promise(r=>setTimeout(r,30));}return false;})()`),true,`${name} ${key} offers ${value}`);};
const value=(name,key)=>b.evaluate(`${cell(name,key)}.value`);
const shown=(name,key)=>b.evaluate(`${cell(name,key)}.selectedOptions[0].textContent`);
let sent=0;
const save=async()=>{
// The last receipt is cleared here, so only the answer's redraw writes one.
await b.evaluate('document.querySelector("#routes-receipt").textContent="";document.querySelector("#routes-form").requestSubmit();true');
await wait(`posts.length>${sent}`);sent++;
const r=await post(writer.address().port,headers,await b.evaluate('posts.at(-1)'));
await b.evaluate(`window.answer(${r.status},${JSON.stringify(r.body)});true`);
await wait('!document.querySelector("#routes-form button[type=submit]")?.textContent.startsWith("Saving")&&document.querySelector("#routes-receipt")?.textContent!==""');
return r;
};
const reload=async()=>{
await b.evaluate('document.querySelector("#routes-reload").click();true');
await wait('!!document.querySelector("#routes-table")&&document.querySelector("#routes-receipt").textContent===""');
};
const vars=()=>JSON.parse(readFileSync(file,'utf8')).roles;
// Read-only: one row per instance in the file, each with its six values, every select disabled.
await b.navigate(`http://127.0.0.1:${port}/#/settings/routing`);
await wait('!!document.querySelector("#routes-table")');
const rows=await b.evaluate("[...document.querySelectorAll('#routes-table tbody tr')].map(r=>[r.querySelector('th').textContent,...[...r.querySelectorAll('select')].map(s=>s.value)])");
assert.deepEqual(rows,Object.entries(vars()).map(([name,{vars:v}])=>[name,...KEYS.map(k=>v[k]??'')]));
assert.ok(rows.length>=5,'every fixture instance has a row');
assert.deepEqual(rows.find(([name])=>name==='reviewer').slice(4),['pi','glm-5.3-flash','low']);
assert.equal(await b.evaluate('document.querySelector("#routes-title").textContent'),'Routing');
assert.equal(await b.evaluate('[...document.querySelectorAll("#routes-form select,#routes-form button[type=submit]")].every(e=>e.disabled)'),true);
// Send mode: a harness, a model, a thinking level and a whole fallback on the coder, saved and
// read back from the file.
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings/routing`);
await wait('document.querySelector("#routes-form button[type=submit]")?.disabled===false');
const before=vars().coder.vars;
await set('coder','harness','claude-code');
await set('coder','model','claude-opus-5-5');
await set('coder','thinking','xhigh');
await set('coder','fallback.harness','pi');
await set('coder','fallback.model','glm-5.3-flash');
await set('coder','fallback.thinking','off');
const saved=await save();
assert.equal(saved.status,200,JSON.stringify(saved.body));
assert.deepEqual(await b.evaluate('posts.at(-1).routes'),{coder:{harness:'claude-code',model:'claude-opus-5-5',thinking:'xhigh',fallback:{harness:'pi',model:'glm-5.3-flash',thinking:'off'}}},'only the changed row is sent');
assert.equal(await receipt(),'Routes saved for coder. The next launch of each uses the new route.');
const after=vars().coder.vars;
assert.deepEqual(KEYS.map(k=>after[k]),['claude-code','claude-opus-5-5','xhigh','pi','glm-5.3-flash','off']);
assert.deepEqual(Object.fromEntries(Object.entries(after).filter(([k])=>!KEYS.includes(k))),Object.fromEntries(Object.entries(before).filter(([k])=>!KEYS.includes(k))),'other vars kept');
assert.equal(await value('coder','harness'),'claude-code','the page draws the saved route');
assert.match(lines.at(-1),/ routes user="jason" instances="coder" done$/);
// A partial fallback goes as it is and the bus refuses it; the draft stays on the page.
const unchanged=readFileSync(file,'utf8');
await set('pm','fallback.harness','pi');
let r=await save();
assert.deepEqual([r.status,r.body.error],[400,'organization-partial-fallback']);
assert.deepEqual(await b.evaluate('posts.at(-1).routes.pm.fallback'),{harness:'pi'});
assert.match(await receipt(),/^organization-partial-fallback: .* Nothing changed\. Draft kept\.$/);
assert.equal(await value('pm','fallback.harness'),'pi');
// A level the new harness doesn't apply: shown as such, and refused.
await reload();
assert.equal(await value('pm','fallback.harness'),'','reload discards the draft');
await set('pm','thinking','minimal');
await set('pm','harness','claude-code');
await wait(`${cell('pm','thinking')}?.selectedOptions[0]?.textContent.includes("(")`);
assert.equal(await shown('pm','thinking'),'minimal (not applied by claude-code)');
r=await save();
assert.deepEqual([r.status,r.body.error],[400,'thinking-unsupported']);
assert.match(await receipt(),/^thinking-unsupported: .* Draft kept\.$/);
assert.deepEqual([await value('pm','harness'),await value('pm','thinking')],['claude-code','minimal']);
// A stale revision: the human narrows plugins.harness to pi outside the page, so the page's read
// is old, and a save on it is refused.
await reload();
const {revision}=await call('plugins');
await call('plugins.set',{revision,plugins:{harness:['pi']}});
const narrowed=readFileSync(file,'utf8');
await set('cto','model','claude-sonnet-5-5'===(await value('cto','model'))?'claude-opus-5-5':'claude-sonnet-5-5');
r=await save();
assert.deepEqual([r.status,r.body.error],[409,'revision-conflict']);
assert.match(await receipt(),/^revision-conflict: the business file changed since you read it\. Read the routes again\. Nothing changed\. Draft kept\.$/);
// Read again: the coder's harness is now off, shown as such, and a thinking change on that route
// is refused harness-off.
await reload();
assert.equal(await shown('coder','harness'),'claude-code (not switched on)');
await set('coder','thinking','high');
r=await save();
assert.deepEqual([r.status,r.body.error],[400,'harness-off']);
assert.match(await receipt(),/^harness-off: .*`mosaic plugins set` changes\. Nothing changed\. Draft kept\.$/);
assert.equal(await value('coder','thinking'),'high');
// Nothing changed is caught on the page and posts nothing.
await reload();
await b.evaluate('document.querySelector("#routes-form").requestSubmit();true');
await wait('document.querySelector("#routes-receipt")?.textContent==="Nothing changed."');
assert.equal(await b.evaluate('posts.length'),sent);
assert.equal(readFileSync(file,'utf8'),narrowed,'no refused save wrote the file');
assert.notEqual(narrowed,unchanged);
}finally{await b.close();await close(web);await close(writer);}
});
test('Routing offers catalogue models only for compatible harnesses and saves a generated entry',{timeout:60000},async t=>{
const root=tmp(t,'provider-route-'),f=fixture(root,'acme',doc=>{doc.plugins['model-provider']=['cliproxy'];return doc;});
const dir=join(root,'config'),file=join(dir,'businesses','acme.json');
const offered={key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'z.ai / fixture-id',available:true,reason:null,harnesses:['pi']};
const modelCatalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}};
const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close());
const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args}));
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));
const b=await browser();t.after(()=>b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed');})()`);
await wait(`!!${cell('coder','model')}`);
assert.equal(await b.evaluate(`[...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id')`),true);
await b.evaluate(`${cell('coder','harness')}.value='claude-code';${cell('coder','harness')}.dispatchEvent(new Event('change',{bubbles:true}));true`);
await wait(`!([...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id'))`);
assert.equal(await b.evaluate(`[...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id')`),false);
await b.evaluate(`${cell('coder','harness')}.value='pi';${cell('coder','harness')}.dispatchEvent(new Event('change',{bubbles:true}));true`);
await wait(`[...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id')`);
await b.evaluate(`${cell('coder','model')}.value='zai-fixture-id';${cell('coder','model')}.dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('#routes-form').requestSubmit();true`);
await wait('posts.length===1'); const payload=await b.evaluate('posts[0]');
const result=await call('routes.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`);
assert.equal(JSON.parse(readFileSync(file,'utf8')).launch.models['zai-fixture-id'].model,'fixture-id');
});
test('Routing switch writes the model-provider setting and keeps other modules', {timeout:60000}, async t => {
const root=tmp(t,'provider-switch-'), f=fixture(root,'acme');
const dir=join(root,'config'),file=join(dir,'businesses','acme.json');
const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)}});t.after(()=>host.close());
const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});
const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args}));
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));
const b=await browser();t.after(()=>b.close());
const settings=await call('plugins');
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(settings)}));if(url==='/api/proxy/plugins/set'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed');})()`);
await wait('document.querySelector("#provider-switch")?.textContent.startsWith("Enable")');
await b.evaluate('document.querySelector("#provider-switch").click();true');
await wait('posts.length===1');const payload=await b.evaluate('posts[0]');
assert.deepEqual(payload.plugins,{'model-provider':['cliproxy']});
const before=JSON.parse(readFileSync(file,'utf8')).plugins;
const result=await call('plugins.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`);
const after=JSON.parse(readFileSync(file,'utf8')).plugins;
assert.deepEqual(after,{...before,'model-provider':['cliproxy']});
});
test('Routing offers a catalogue model absent from an otherwise valid legacy model table',{timeout:60000},async t=>{
const offered={key:'fixture-provider-model',module:'fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture model',available:true,reason:null,harnesses:['pi']};
const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi'],models:['legacy'],providerModels:{},levels:{pi:['low']},instances:{coder:{harness:'pi',model:'legacy',thinking:'low',fallback:null}},catalogue:{status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[],legacy:{}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture');},routes:async()=>view};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));
const b=await browser();t.after(()=>b.close());await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
const options=await b.evaluate(`(async()=>{for(let n=0;n<200;n++){const el=document.querySelector('select[aria-label="coder model"]');if(el)return [...el.options].map(o=>o.value);await new Promise(r=>setTimeout(r,20));}throw Error('routing missing');})()`);
assert.ok(options.includes('fixture-provider-model'),'the offered model is missing from Routing');
});
test('Routing marks a saved legacy selection unavailable and filters incompatible legacy models',{timeout:60000},async t=>{
const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi','claude-code'],models:['legacy-missing','legacy-other'],providerModels:{},levels:{pi:['low'],'claude-code':['low']},instances:{coder:{harness:'claude-code',model:'legacy-missing',thinking:'low',fallback:null}},catalogue:{status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[{key:'fixture-other',module:'fixture',upstream:'fixture',provider:'fixture',model:'fixture-other',label:'Fixture',available:true,reason:null,harnesses:['pi']}],notices:[],legacy:{'legacy-missing':{module:'fixture',upstream:'fixture',model:'fixture-missing'},'legacy-other':{module:'fixture',upstream:'fixture',model:'fixture-other'}}}};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture');},routes:async()=>view},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));
const b=await browser();t.after(()=>b.close());await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
const options=await b.evaluate(`(async()=>{for(let n=0;n<200;n++){const el=document.querySelector('select[aria-label="coder model"]');if(el)return [...el.options].map(o=>({value:o.value,label:o.textContent}));await new Promise(r=>setTimeout(r,20));}throw Error('routing missing');})()`);
assert.deepEqual(options,[{value:'legacy-missing',label:'legacy-missing (unavailable)'}]);
});
test('enabling a discovered harness refreshes Routing and offers its compatible model', {timeout:60000}, async t => {
const root=tmp(t,'harness-switch-'),f=fixture(root,'acme',doc=>{doc.plugins.harness=['claude-code'];doc.plugins['model-provider']=['cliproxy'];for(const r of Object.values(doc.roles)){r.vars.harness='claude-code';delete r.vars['fallback.harness'];delete r.vars['fallback.model'];delete r.vars['fallback.thinking'];}return doc;});
const dir=join(root,'config');
const offered={key:'fixture-pi',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-pi',label:'Fixture pi',available:true,reason:null,harnesses:['pi']};
const modelCatalogue={read:async()=>({status:'ready',observedAt:'2026-11-07T02:24:02.021Z',models:[offered],notices:[]}),invalidate(){}};
const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close());
const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args}));
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture')},routes:()=>call('routes')},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));
const b=await browser();t.after(()=>b.close());const settings=await call('plugins');
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(settings)}));if(url==='/api/proxy/plugins/set'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
await wait('!!document.querySelector("[data-harness=pi]")');
await b.evaluate('document.querySelector("[data-harness=pi]").click();document.querySelector("#harness-save").click();true');await wait('posts.length===1');
const payload=await b.evaluate('posts[0]');assert.deepEqual(payload.plugins.harness,['claude-code','pi']);assert.equal(payload.revision,settings.revision);
const result=await call('plugins.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`);
const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`;
await wait(`[...${cell('coder','harness')}.options].some(o=>o.value==='pi')`);
await b.evaluate(`${cell('coder','harness')}.value='pi';${cell('coder','harness')}.dispatchEvent(new Event('change',{bubbles:true}));true`);
await wait(`[...${cell('coder','model')}.options].some(o=>o.value==='fixture-pi')`);
assert.match(await b.evaluate('document.querySelector("#harness-status").textContent'),/^Harnesses saved\./);
await b.evaluate('document.querySelector("#routes-reload").click();true');await wait('document.querySelector("#routes-reload")?.hidden');
await b.evaluate('document.querySelector("[data-harness=claude-code]").click();document.querySelector("#harness-save").click();true');
await wait('document.querySelector("#harness-status")?.textContent.includes("saved route")');
assert.equal(await b.evaluate('posts.length'),1,'a harness used by a saved route is not switched off');
});
test('quick fallback edits survive the harness redraw', {timeout:60000}, async t => {
const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi','claude-code'],models:['first','second'],providerModels:{},levels:{pi:['low','high'],'claude-code':['low','high']},instances:{coder:{harness:'pi',model:'first',thinking:'low',fallback:{harness:'pi',model:'first',thinking:'low'}}},catalogue:{status:'disabled'}};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture')},routes:async()=>view},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));const b=await browser();t.after(()=>b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.fetch=(u,o)=>u==='/api/config'?Promise.resolve(new Response(JSON.stringify({send:true}))):original(u,o);`});await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
const cell=key=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify(['coder',key])}']`)})`;
await b.evaluate(`(async()=>{for(let n=0;n<200&&!${cell('fallback.harness')};n++)await new Promise(r=>setTimeout(r,30));})()`);
await b.evaluate(`(async()=>{for(let n=0;n<200&&document.querySelector('#routes-form button[type=submit]').disabled;n++)await new Promise(r=>setTimeout(r,30));})()`);await b.viewport(390,900);assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('.route-actions')).position`),'fixed');assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('#routes-reload')).display`)!=='none',true);await b.viewport(1440,1000);assert.notEqual(await b.evaluate(`getComputedStyle(document.querySelector('.route-actions')).position`),'fixed');
// Inject the two quick edits after the next render has built its markup but before it paints.
await b.evaluate(`{const status=document.querySelector('#fresh-bus'),descriptor=Object.getOwnPropertyDescriptor(Element.prototype,'innerHTML');let armed=true;Object.defineProperty(status,'innerHTML',{configurable:true,get(){return descriptor.get.call(this)},set(value){descriptor.set.call(this,value);if(!armed)return;armed=false;for(const [e,v] of [[${cell('fallback.model')},'second'],[${cell('fallback.thinking')},'high']]){e.value=v;e.dispatchEvent(new Event('change',{bubbles:true}));}}});const e=${cell('fallback.harness')};e.value='claude-code';e.dispatchEvent(new Event('change',{bubbles:true}));}true`);
await b.evaluate('new Promise(r=>setTimeout(r,500))');
assert.deepEqual(await b.evaluate(`[${cell('fallback.harness')}.value,${cell('fallback.model')}.value,${cell('fallback.thinking')}.value]`),['claude-code','second','high']);
});