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

168 lines
17 KiB
JavaScript

// Row 194 (#1692, D7, T16): Settings' Agents section. Read-only mode shows every action unavailable
// with the instance CLI named, and a run a fire's stop left alive is flagged (S6). In send mode a
// launch posts once and follows its request until it closes; regenerate and delete take a second
// click and name what stays; a terminate carries its reason; a refusal shows the bus's message and
// nothing is retried.
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 hostile='<img src=x onerror="window.injected=true">';
const REV='a'.repeat(64), ID='0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired:['old-coder'],
instances:{coder:{definition:'coder',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:['run-0']},[hostile]:{definition:'coder',vars:{},launched:false,launcher:false,protected:false,kept:true,running:[],alive:[hostile]},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true,kept:false,endProtected:true,running:[]},bean:{definition:'companion',vars:{},launched:false,launcher:false,protected:false,kept:false,endProtected:true,running:[]}},
definitions:{assistant:{services:[],accepted:false},coder:{services:[],accepted:true},reviewer:{services:[],accepted:true}}};
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
// POSTs answer from `replies`, request reads from `polls`; both record what was asked.
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];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(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return 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<400;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("#agents-receipt").textContent');
const click=sel=>b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});if(e.closest('.agent-menu')?.hidden)e.closest('.agent-menu').previousElementSibling.click();e.click();return true;})()`);
// Read-only: the table, the unavailable line, every action disabled, nothing injected.
await b.navigate(`http://127.0.0.1:${port}/#/settings/agents`);
await wait('!!document.querySelector("#agents-table")');
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent}))"),[
['coder','coder','Yes','No','run-1 still running after fire: run-0'],[hostile,'coder','No','Yes',`None still running after fire: ${hostile}`],['assistant','assistant','No','No','None'],['bean','companion','No','No','None'],
]);
assert.equal(await b.evaluate('window.injected===true'),false);
// D7 (S6): a run a fire's stop left alive is flagged beside the running runs, as text.
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table .agent-alive code')].map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent})"),['run-0',hostile]);
assert.match(await b.evaluate('document.querySelector(".settings-readonly").textContent'),/Read only/);
assert.equal(await b.evaluate(`document.querySelectorAll('#agents-table .agent-start-hint').length`),2);
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action],#agents-section .agent-more").length'),0);
assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./);
// Send mode: a launch posts once, shows the request and follows it to its close.
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings/agents`);
await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]')?.disabled===false`);
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false);
assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-dialog="delete"][data-agent-name="assistant"]')`),false);
assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-action="launch"][data-agent-name="assistant"]:not(:disabled)')`),false);
assert.match(await b.evaluate(`document.querySelector('[data-agent-name="assistant"]').closest("tr").textContent`),/Starts when you message it/);
assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-dialog="delete"][data-agent-name="bean"]')`),false);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="hire"]').dataset.agentName`),'reviewer');
await b.evaluate(`replies.push([200,{request:'${ID}',at:'2026-10-20T00:00:00Z'}]);polls.push([200,{request:'${ID}',action:'launch',instance:'coder',state:'taken',done:null,cancelled:null}],[200,{request:'${ID}',action:'launch',instance:'coder',state:'closed',done:{run:'run-2',transaction:null,refusal:null},cancelled:null}]);true`);
await click('[data-agent-action="launch"][data-agent-name="coder"]');
await wait('!!document.querySelector("#agents-pending")');
assert.match(await b.evaluate('document.querySelector("#agents-pending").textContent'),new RegExp(`Request ${ID} to launch coder`));
assert.equal(await b.evaluate('document.querySelector(\'[data-agent-action="launch"][data-agent-name="coder"]\').disabled'),true,'one request at a time');
await wait('document.querySelector("#agents-receipt")?.textContent==="Launched coder (run run-2)."');
assert.deepEqual(await b.evaluate('posts'),[['/api/agent/launch',{instance:'coder'}]]);
assert.deepEqual(await b.evaluate('reads'),[`/api/agent/request?id=${ID}`,`/api/agent/request?id=${ID}`]);
assert.equal(await b.evaluate('!!document.querySelector("#agents-pending")'),false);
// Regenerate and delete: the first click only asks; Cancel asks nothing; the confirm posts.
await click('[data-agent-dialog="regenerate"][data-agent-name="coder"]');
await wait('document.querySelector("#agent-dialog")?.open');
assert.match(await b.evaluate('document.querySelector("#agent-dialog").textContent'),/Regenerate coder\?/);
await click('#agent-dialog-cancel');
await wait('!document.querySelector("#agent-dialog").open');
await click('[data-agent-dialog="delete"][data-agent-name="coder"]');
await wait('document.querySelector("#agent-dialog")?.open');
assert.match(await b.evaluate('document.querySelector("#agent-dialog").textContent'),/Its evidence and its workspace under \.retired stay\./);
assert.equal(await b.evaluate('posts.length'),1,'asking posts nothing');
await b.evaluate(`replies.push([200,{request:'${ID}',at:'x'}]);polls.push([200,{request:'${ID}',action:'delete',instance:'coder',state:'closed',done:{run:null,transaction:'t-9',refusal:null},cancelled:null}]);true`);
await click('#agent-dialog-confirm');
await wait('document.querySelector("#agents-receipt")?.textContent==="Deleted coder."');
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/instances',{revision:REV,instance:'coder',op:'delete'}]);
// sage-1610 (b): a delete that closes with a tracker bot names it for removal in Vikunja; a bot
// name outside the pattern is not shown.
for (const [bot,text] of [['bot-acme-coder','Deleted coder. Remove the Vikunja bot user bot-acme-coder and any token it holds by hand in Vikunja.'],[hostile,'Deleted coder.']]) {
await b.evaluate(`replies.push([200,{request:'${ID}',at:'x'}]);polls.push([200,{request:'${ID}',action:'delete',instance:'coder',state:'closed',done:{run:null,transaction:'t-9',refusal:null,bot:${JSON.stringify(bot)}},cancelled:null}]);true`);
await click('[data-agent-dialog="delete"][data-agent-name="coder"]');
await wait('document.querySelector("#agent-dialog")?.open');
await click('#agent-dialog-confirm');
await wait(`document.querySelector("#agents-receipt")?.textContent===${JSON.stringify(text)}`);
}
assert.equal(await b.evaluate('window.injected===true'),false);
// Create names whether the bus must restart, in the words bus.js uses for the same answer (M63).
const typeCreate=(field,value)=>b.evaluate(`(()=>{const e=document.querySelector('#instance-add-form [data-instance-field="${field}"]');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
for (const [restart,text] of [[true,'Added rev-2. Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'],[false,'Added rev-2. No restart: the launcher reads the change at its next launch check.']]) {
await typeCreate('instance','rev-2');
await typeCreate('definition','reviewer');
await b.evaluate(`replies.push([200,{...${JSON.stringify(view)},transaction:'t-3',restart:${restart}}]);true`);
await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true');
await wait(`document.querySelector("#instances-receipt")?.textContent===${JSON.stringify(text)}`);
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/instances',{revision:REV,instance:'rev-2',op:'add',definition:'reviewer'}]);
}
// Terminate carries the typed reason; a closed refusal shows its reason.
await b.evaluate(`replies.push([200,{request:'${ID}',runs:['run-1'],cancelled:[],taken:[]}]);polls.push([200,{request:'${ID}',action:'end',instance:'coder',state:'closed',done:{run:null,transaction:null,refusal:'not-held'},cancelled:null,reason:'the run no longer holds the instance'}]);true`);
await click('[data-agent-dialog="end"][data-agent-name="coder"]');
await b.evaluate('document.querySelector("#agent-dialog-reason").value="done for today"'); await click('#agent-dialog-confirm');
await wait('document.querySelector("#agents-receipt")?.textContent==="coder: not-held: the run no longer holds the instance."');
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/agent/terminate',{instance:'coder',reason:'done for today'}]);
// A refusal shows the bus's message and follows nothing; Keep posts the read's revision.
await b.evaluate(`replies.push([400,{error:'no-route',message:'no-route: set a route in Routing first. Nothing changed.',sent:false}]);true`);
const before=await b.evaluate('reads.length');
await click('[data-agent-action="launch"][data-agent-name="coder"]');
await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("no-route:")');
assert.equal(await b.evaluate('reads.length'),before);
await b.evaluate(`replies.push([200,{...${JSON.stringify(view)},transaction:'t-2',restart:false}]);true`);
await click('[data-agent-action="keep"][data-agent-name="coder"]');
await wait('document.querySelector("#agents-receipt")?.textContent==="Kept coder. No restart: the launcher reads the change at its next launch check."');
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/veto',{revision:REV,op:'keep',name:'coder'}]);
// An unknown outcome says so and is not retried.
await b.evaluate(`replies.push([503,{error:'outcome-unknown',sent:'unknown'}]);true`);
const sent=await b.evaluate('posts.length');
await click('[data-agent-action="nohire"][data-agent-name="coder"]');
await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("Outcome unknown")');
assert.equal(await b.evaluate('posts.length'),sent+1);
}finally{await b.close();await close(web);}
});
// Row 256 (#1761, T7, T8): the human's listing carries endProtected false for a creative instance
// (the server's delete check for that reader), so the human's page offers Delete for it. The tester's
// page offers no Delete even on that same listing; the tester's own listing marks bean endProtected
// as well (bus creative.test T6).
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
const port=web.address().port;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const del=name=>`document.querySelector('[data-agent-dialog="delete"][data-agent-name="${name}"]')`;
// T7: the human's page.
await b.navigate(`http://127.0.0.1:${port}/?human=1#/settings/agents`);
await wait(`!!document.querySelector('[aria-label="More actions for bean"]')`);
assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]:not(:disabled)')`),false);
assert.equal(await b.evaluate(`${del('bean')}?.disabled`),false);
assert.equal(await b.evaluate(`!!${del('assistant')}`),false);
assert.equal(await b.evaluate(`!!${del('coder')}`),true);
// T8: the tester's page, on the same listing.
await b.navigate(`http://127.0.0.1:${port}/?tester=1#/settings/agents`);
await wait(`!!document.querySelector('#tester-banner')&&!!document.querySelector('[aria-label="More actions for bean"]')`);
assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]:not(:disabled)')`),false);
assert.equal(await b.evaluate(`document.querySelectorAll('[data-agent-dialog="delete"]').length`),0);
}finally{await b.close();await close(web);}
});
test('a refused launch shows its code and skill activation hint', {timeout:30000}, async () => {
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage')},instances:async()=>view};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try {
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/agent/launch')return new Response(JSON.stringify({request:'${ID}'}));if(String(url).startsWith('/api/agent/request'))return new Response(JSON.stringify({request:'${ID}',action:'launch',instance:'coder',state:'closed',done:{refusal:'skill-not-enabled'},reason:"A skill this role needs isn't turned on: scripts/skill.sh activate <name>"}));return original(url,options)};`});
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/agents`);
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error('wait failed: '+${JSON.stringify(expr)})})()`);
await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]')`);
await b.evaluate(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]').click()`);
await wait(`document.querySelector('#agents-receipt')?.textContent.includes('scripts/skill.sh activate')`);
const receipt=await b.evaluate(`document.querySelector('#agents-receipt').textContent`);
assert.match(receipt,/skill-not-enabled/); assert.match(receipt,/scripts\/skill\.sh activate/);
} finally {await b.close();await close(web)}
});