git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
267 lines
27 KiB
JavaScript
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:'coder',delivery:'coder'},roles:{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:'coder',delivery:'coder'},roles:{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:'coder',delivery:'coder'},roles:{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:'coder',delivery:'coder'},roles:{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']);
|
|
});
|