Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
128 lines
17 KiB
JavaScript
128 lines
17 KiB
JavaScript
// REQ-WEB-1: actual Settings form over fixture reads and intercepted sends.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {browser} from './browser.mjs';
|
|
import {startServer} from '../src/serve.mjs';
|
|
import {close} from './fixture.mjs';
|
|
const launch={business:'acme',revision:'a'.repeat(64),models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}},instances:{pm:{modelKey:'opus'},cto:{modelKey:'opus'},coder:{modelKey:'sonnet'},reviewer:{modelKey:'opus'}},usage:{total:1,providers:{anthropic:1},models:{anthropic:{'claude-opus-5-5':1}}}};
|
|
test('Settings form shows every scope and held role; gated save, exact payload, retained draft and no automatic retry', {timeout:90000},async()=>{
|
|
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0})};const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch})}}),b=await browser();
|
|
try{
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));if(mode==='unknown')throw Error('fixture lost answer');if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});return new Response(JSON.stringify({...fixture,...posts.at(-1),revision:'b'.repeat(64)}));}return original(url,options);};`});
|
|
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
|
|
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("#launch-settings-form")');assert.equal(await b.evaluate('document.querySelector("#launch-settings-form button[type=submit]").disabled'),true);
|
|
// Row 65 (mos-0840): the Role models selects moved to the routing table, so this form has none.
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form select").length'),0);assert.equal(await b.evaluate('document.querySelectorAll("#launch-settings-form input[type=number]").length'),4);
|
|
await b.navigate(`http://127.0.0.1:${web.address().port}/?send=1#/settings`);await wait('document.querySelector("#launch-settings-form button[type=submit]")?.disabled===false');
|
|
const change=async(parts,value)=>b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(parts))});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
|
const submit=()=>b.evaluate('document.querySelector("#launch-settings-form").requestSubmit();true');
|
|
await change(['total'],'0');await change(['providers','anthropic'],'12');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');const [payload]=await b.evaluate('posts');assert.equal(payload.limits.total,0);assert.equal(payload.limits.providers.anthropic,12);assert.equal('instances' in payload,false);assert.equal(payload.revision,launch.revision);assert.deepEqual(payload.models,launch.models);
|
|
await change(['total'],'9');await b.evaluate('mode="unknown"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Outcome unknown")');assert.equal(await b.evaluate('posts.length'),2);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');await b.evaluate('new Promise(r=>setTimeout(r,300))');assert.equal(await b.evaluate('posts.length'),2);
|
|
await b.evaluate('mode="refused"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("fixture conflict")');assert.equal(await b.evaluate('posts.length'),3);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');
|
|
}finally{await b.close();await close(web);}
|
|
});
|
|
// Row 155 (#1649, Q169): the Assistant section. A save sends only the changed fields with the
|
|
// read's revision; the name and stop buttons post their own routes; a refusal keeps the draft.
|
|
// The page reads settings again after a save, so the mock serves the saved state from then on.
|
|
test('Q169 Assistant section: gated, changed fields only, name and stop posts, retained draft and no automatic retry',{timeout:90000},async()=>{
|
|
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
|
|
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
|
|
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
|
try{
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'){const body=JSON.parse(options.body);posts.push([url,body]);if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});if(url==='/api/name')return new Response(JSON.stringify({...body,at:'2026-10-18T01:00:00.000Z'}));if(url==='/api/assistant-stop')return new Response(JSON.stringify({stopped:[{thread:'t-1',run:'a-run'},{thread:'t-2',run:'b-run'}]}));const a=body.assistants.assistant;window.saved={...fixture,revision:'b'.repeat(64),assistants:{assistant:{...fixture.assistants.assistant,...a}}};return new Response(JSON.stringify(window.saved));}if(url==='/api/settings'&&window.saved){const read=await (await original(url,options)).json();return new Response(JSON.stringify({...read,launch:window.saved}));}return original(url,options);};`});
|
|
const port=web.address().port, q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
|
|
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 b.navigate(`http://127.0.0.1:${port}/#/settings`);await wait(`!!${q('.assistant-form')}`);
|
|
assert.equal(await b.evaluate(`${q('.assistant-form button[type=submit]')}.disabled&&${q('[data-assistant-stop]')}.disabled&&${q('[data-assistant-name]')}.disabled`),true);
|
|
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`);await wait(`${q('.assistant-form button[type=submit]')}?.disabled===false`);
|
|
assert.equal(await b.evaluate(`${q('[data-assistant-field=name]')}.value`),'piper');assert.equal(await b.evaluate(`${q('[data-assistant-field=definition]')}.value`),'assistant');
|
|
const change=async(field,value)=>b.evaluate(`(()=>{const e=${q('.assistant-form')}.querySelector('[data-assistant-field="${field}"]');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
|
const receipt=text=>wait(`${q('[data-assistant-receipt]')}?.textContent.includes(${JSON.stringify(text)})`);
|
|
await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('Nothing changed');assert.equal(await b.evaluate('posts.length'),0);
|
|
await change('vars','{"model":"claude-opus-5-5","thinking":"high"}');await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('Assistant settings saved');
|
|
assert.deepEqual(await b.evaluate('posts[0]'),['/api/settings',{revision:launch.revision,assistants:{assistant:{vars:{model:'claude-opus-5-5',thinking:'high'}}}}]);
|
|
await change('tracker','[1]');await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('must be a JSON object');assert.equal(await b.evaluate('posts.length'),1);
|
|
await b.evaluate('mode="refused"');await change('tracker','{"project":"P19"}');await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('fixture conflict');
|
|
assert.deepEqual(await b.evaluate('posts[1]'),['/api/settings',{revision:'b'.repeat(64),assistants:{assistant:{tracker:{project:'P19'}}}}]);assert.equal(await b.evaluate(`${q('[data-assistant-field=tracker]')}.value`),'{"project":"P19"}');
|
|
await b.evaluate('new Promise(r=>setTimeout(r,300))');assert.equal(await b.evaluate('posts.length'),2);
|
|
await b.evaluate('mode="success"');await change('name','wren');await b.evaluate(`${q('[data-assistant-name]')}.click();true`);await receipt('Seat name set to wren');
|
|
assert.deepEqual(await b.evaluate('posts[2]'),['/api/name',{instance:'assistant',name:'wren'}]);
|
|
await b.evaluate(`${q('[data-assistant-stop]')}.click();true`);await receipt('Stopped 2 runs');assert.deepEqual(await b.evaluate('posts[3]'),['/api/assistant-stop',{instance:'assistant'}]);assert.equal(await b.evaluate('posts.length'),4);
|
|
}finally{await b.close();await close(web);}
|
|
});
|
|
// Row 167 (#1664): the names read fails like the usage read. A 403 on it gives the same refused
|
|
// page as a 403 on usage; any other failure keeps the Assistant section and says so.
|
|
test('Row 167 a refused names read gives the refused usage page; another names failure keeps the Assistant section',{timeout:90000},async()=>{
|
|
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
|
|
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
|
|
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
|
try{
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const query=new URL(location.href).searchParams,refuse=query.get('refuse'),fail=query.get('fail'),original=window.fetch;window.fetch=async(url,options={})=>{if(refuse&&url.startsWith('/api/bus/'+refuse))return new Response(JSON.stringify({error:'human-required',message:'fixture refusal'}),{status:403});if(fail&&url.startsWith('/api/bus/'+fail))return new Response(JSON.stringify({error:'bus-unreachable',message:'fixture failure'}),{status:503});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 refused=async read=>{await b.navigate(`http://127.0.0.1:${port}/?refuse=${read}#/settings`);await wait('document.querySelector("#bus-view")?.textContent.includes("Bus refused the read")');return b.evaluate('document.querySelector("#bus-view").innerHTML');};
|
|
const usage=await refused('usage'), names=await refused('names');
|
|
assert.match(usage,/refused: <code>human-required<\/code>/);
|
|
assert.equal(names,usage);
|
|
assert.equal(names.includes('assistant-form')||names.includes('launch-settings-form'),false);
|
|
await b.navigate(`http://127.0.0.1:${port}/?fail=names#/settings`);await wait('!!document.querySelector(".assistant-form")');
|
|
assert.equal(await b.evaluate('document.querySelector(".assistant-form").textContent.includes("The current name could not be read.")'),true);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=name]").value'),'');
|
|
assert.equal(await b.evaluate('!!document.querySelector("#launch-settings-form")'),true);
|
|
}finally{await b.close();await close(web);}
|
|
});
|
|
// Row 167 (#1664): one settings snapshot. Each POST answers the fixture with the next revision
|
|
// (b, c, then d), and reads after it serve that answer, as the server would. A save in one form
|
|
// gives the other form's open draft the revision it returned.
|
|
const oneSnapshot=`window.posts=[];window.saved=null;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));window.saved={...(window.saved||window.fixture),revision:'bcd'[posts.length-1].repeat(64)};return new Response(JSON.stringify(window.saved));}if(url==='/api/settings'&&window.saved){const read=await (await original(url,options)).json();return new Response(JSON.stringify({...read,launch:window.saved}));}return original(url,options);};`;
|
|
async function snapshotPage(){
|
|
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
|
|
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
|
|
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.fixture=${JSON.stringify(withAssistant)};${oneSnapshot}`});
|
|
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
|
|
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 b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
|
|
await wait(`${q('#launch-settings-form button[type=submit]')}?.disabled===false&&${q('.assistant-form button[type=submit]')}?.disabled===false`);
|
|
const total=()=>b.evaluate(`[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(['total']))}).value`);
|
|
return {web,b,q,wait,total,
|
|
setTotal:value=>b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(['total']))});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`),
|
|
setTracker:value=>b.evaluate(`(()=>{const e=${q('.assistant-form [data-assistant-field="tracker"]')};e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`),
|
|
tracker:()=>b.evaluate(`${q('.assistant-form [data-assistant-field="tracker"]')}.value`),
|
|
submitLaunch:()=>b.evaluate(`${q('#launch-settings-form')}.requestSubmit();true`),
|
|
submitAssistant:()=>b.evaluate(`${q('.assistant-form')}.requestSubmit();true`),
|
|
};
|
|
}
|
|
test('Row 167 with a limits draft open, an assistant save gives the limits submit its revision and the typed limit stays',{timeout:90000},async()=>{
|
|
const p=await snapshotPage();
|
|
try{
|
|
await p.setTotal('7');await p.setTracker('{"project":"P19"}');await p.submitAssistant();
|
|
await p.wait(`${p.q('[data-assistant-receipt]')}?.textContent.includes("Assistant settings saved")`);
|
|
assert.equal((await p.b.evaluate('posts[0]')).revision,launch.revision);
|
|
assert.equal(await p.total(),'7');
|
|
await p.submitLaunch();await p.wait(`${p.q('#launch-settings-receipt')}?.textContent.includes("saved")`);
|
|
const second=await p.b.evaluate('posts[1]');
|
|
assert.equal(second.revision,'b'.repeat(64));
|
|
assert.equal(second.limits.total,7);
|
|
}finally{await p.b.close();await close(p.web);}
|
|
});
|
|
test('Row 167 with an assistant draft open, a launch save gives the assistant submit its revision, and reload reads the current one',{timeout:90000},async()=>{
|
|
const p=await snapshotPage();
|
|
try{
|
|
await p.setTracker('{"project":"P19"}');await p.setTotal('7');await p.submitLaunch();
|
|
await p.wait(`${p.q('#launch-settings-receipt')}?.textContent.includes("saved")`);
|
|
assert.equal((await p.b.evaluate('posts[0]')).revision,launch.revision);
|
|
assert.equal(await p.tracker(),'{"project":"P19"}');
|
|
await p.submitAssistant();await p.wait(`${p.q('[data-assistant-receipt]')}?.textContent.includes("Assistant settings saved")`);
|
|
assert.deepEqual(await p.b.evaluate('posts[1]'),{revision:'b'.repeat(64),assistants:{assistant:{tracker:{project:'P19'}}}});
|
|
// Someone else writes, so the file is at d. The reload button clears the snapshot even with an
|
|
// assistant draft open, and the draft's submit then carries d.
|
|
await p.setTracker('{"project":"P20"}');await p.b.evaluate(`window.saved={...window.saved,revision:'d'.repeat(64)};${p.q('#launch-settings-reload')}.click();true`);
|
|
await p.wait(`${p.q('#launch-settings-receipt')}?.textContent===''`);
|
|
assert.equal(await p.tracker(),'{"project":"P20"}');
|
|
await p.submitAssistant();await p.wait('posts.length===3');
|
|
assert.deepEqual(await p.b.evaluate('posts[2]'),{revision:'d'.repeat(64),assistants:{assistant:{tracker:{project:'P20'}}}});
|
|
}finally{await p.b.close();await close(p.web);}
|
|
});
|