150 lines
20 KiB
JavaScript
150 lines
20 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/limits`);
|
|
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/limits`);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/assistant`);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/assistant`);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/${read==='usage'?'tokens':'assistant'}`);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/assistant`);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")'),false);
|
|
}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/limits`);
|
|
await wait(`${q('#launch-settings-form button[type=submit]')}?.disabled===false`);
|
|
const go=async page=>{await b.evaluate(`location.hash="#/settings/${page}"`);await wait(`document.querySelector("#settings-page-title")?.textContent===${JSON.stringify(page==='limits'?'Launch limits':'Assistant')}`);};
|
|
const total=async()=>{await go('limits');return b.evaluate(`[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(['total']))}).value`);};
|
|
return {web,b,q,wait,total,go,
|
|
setTotal:async value=>{await go('limits');return 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:async value=>{await go('assistant');return 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:async()=>{await go('assistant');return b.evaluate(`${q('.assistant-form [data-assistant-field="tracker"]')}.value`);},
|
|
submitLaunch:async()=>{await go('limits');return b.evaluate(`${q('#launch-settings-form')}.requestSubmit();true`);},
|
|
submitAssistant:async()=>{await go('assistant');return 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.go('limits');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);}
|
|
});
|
|
|
|
test('launch settings mark invalid fields and show saving, saved and refused states', {timeout:90000}, async()=>{
|
|
const bus={messages:async()=>({messages:[],unread:0})};
|
|
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',launch,notifier:{}})}}),b=await browser();
|
|
try {
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];const original=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));return new Promise(resolve=>window.answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status})));}return original(url,options);};`});
|
|
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/limits`);await wait('!!document.querySelector("#launch-settings-form")');
|
|
const set=value=>b.evaluate(`(()=>{const e=document.querySelector('#launch-settings-form input[type=number]');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#launch-settings-form').requestSubmit()})()`);
|
|
await set('-1');await wait('document.querySelector("[aria-invalid=true]")');
|
|
assert.equal(await b.evaluate('posts.length'),0);
|
|
assert.match(await b.evaluate('document.querySelector("#launch-settings-receipt").textContent'),/whole numbers/);
|
|
await set('2');await wait('document.querySelector("#launch-settings-form button[type=submit]")?.textContent.includes("Saving")');
|
|
assert.equal(await b.evaluate('[...document.querySelectorAll("#launch-settings-form input")].every(e=>e.disabled)'),true);
|
|
await b.evaluate(`answer(200,${JSON.stringify({...launch,revision:'b'.repeat(64)})})`);await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("saved")');
|
|
assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").classList.contains("settings-success")'),true);
|
|
await set('3');await wait('posts.length===2');await b.evaluate(`answer(409,{message:'fixture conflict',sent:false})`);await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("Draft kept")');
|
|
assert.equal(await b.evaluate('document.querySelector("#launch-settings-receipt").classList.contains("err")'),true);
|
|
assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'3');
|
|
} finally {await b.close();await close(web);}
|
|
});
|