// Row 165 (#1662, Q171, REQ-ROLE-3): Settings shows the role instances, offers only the // definitions add and set accept, names the ones that declare credentials as hand edits, and posts // one add or change with the read's revision. The receipt names a needed bus restart and offers no // way to run it. A refusal keeps the draft. 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=''; const REV='a'.repeat(64); const view={business:'acme',revision:REV,launch:true, instances:{chief:{definition:'chief-of-staff',vars:{},launched:false,launcher:true,protected:false},coder:{definition:'coder',vars:{note:hostile},launched:true,launcher:false,protected:false},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true}}, definitions:{assistant:{services:[],accepted:false},coder:{services:['gitea'],accepted:false},'merge-gate':{services:[],accepted:true},reviewer:{services:['gitea','vikunja'],accepted:false},'security-reviewer':{services:[],accepted:true}}}; test('row 165 Settings: role instances read, add with a restart named, change without one, a refusal keeps the draft',{timeout:90000},async()=>{ // The server's read follows each write, so a poll between steps shows what the stub answered. let current=view; const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>current}; 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{ // The stub answers each POST with the next queued reply and records the body. await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];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/instances'&&options.method==='POST'){posts.push(JSON.parse(options.body));const [status,body]=replies.shift();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<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("#instances-receipt").textContent'); const set=(form,field,value)=>b.evaluate(`(()=>{const e=document.querySelector('#${form} [data-instance-field="${field}"]');if(e.type==='checkbox')e.checked=${JSON.stringify(value)};else e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`); // Read-only: the table, the accepted definitions only, the hand-edit line, every control disabled. 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,3).map(c=>{const copy=c.cloneNode(true);copy.querySelector('.agent-mobile-label')?.remove();return copy.textContent}))"),[ ['chief','chief-of-staff','Launcher'],['coder','coder','Yes'],['assistant','assistant','No'], ]); assert.equal(await b.evaluate('window.injected===true'),false); assert.equal(await b.evaluate('document.querySelectorAll(".agent-more,.instance-form").length'),0); // Send mode: an add posts the read's revision and the fields, and the receipt names the restart. await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings/agents`); await wait('document.querySelector("#instance-add-form button")?.disabled===false'); assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instance-add-form [data-instance-field=definition] option')].map(o=>o.value)"),['','merge-gate','security-reviewer']); assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Definitions that declare credentials are a hand edit: coder \(gitea\), reviewer \(gitea, vikunja\)\./); assert.equal(await b.evaluate(`!!document.querySelector('[data-agent-edit="assistant"]')`),false); await set('instance-add-form','instance','sec-1'); await set('instance-add-form','definition','security-reviewer'); await set('instance-add-form','launch',true); current={...view,revision:'b'.repeat(64)}; await b.evaluate(`replies.push([200,${JSON.stringify({...current,transaction:'t-1',restart:true})}]);true`); await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true'); await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Added")'); assert.deepEqual(await b.evaluate('posts.at(-1)'),{revision:REV,instance:'sec-1',op:'add',definition:'security-reviewer',launch:true}); assert.equal(await receipt(),"Added sec-1. Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it."); assert.equal(await b.evaluate('[...document.querySelectorAll("button")].some(e=>/restart/i.test(e.textContent))'),false,'the page offers no restart'); // A change sends the new revision and only what changed; a launch-only change needs no restart. await b.evaluate(`document.querySelector('[data-agent-edit="coder"]').click()`); await wait('!!document.querySelector("#instance-change-form")'); await set('instance-change-form','launch','no'); current={...view,revision:'c'.repeat(64)}; await b.evaluate(`replies.push([200,${JSON.stringify({...current,transaction:'t-2',restart:false})}]);true`); await b.evaluate('document.querySelector("#instance-change-form").requestSubmit();true'); await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Changed")'); assert.deepEqual(await b.evaluate('posts.at(-1)'),{revision:'b'.repeat(64),instance:'coder',op:'change',launch:false}); assert.equal(await receipt(),'Changed coder. No restart: the launcher reads the change at its next launch check.'); // Nothing chosen is caught on the page; a refusal shows the server's reason and keeps the draft. await b.evaluate('document.querySelector("#instance-change-form").requestSubmit();true'); await wait('document.querySelector("#instances-receipt")?.textContent==="Nothing changed."'); assert.equal(await b.evaluate('posts.length'),2); await set('instance-add-form','instance','gate-1'); await set('instance-add-form','definition','merge-gate'); await set('instance-add-form','vars','{"model":"x"}'); await b.evaluate(`replies.push([400,{error:'organization-credentials-out-of-scope',message:'organization-credentials-out-of-scope: that definition declares credentials. Nothing changed.',sent:false}]);true`); await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true'); await wait('document.querySelector("#instances-receipt")?.textContent.endsWith("Draft kept.")'); assert.match(await receipt(),/^organization-credentials-out-of-scope: that definition declares credentials\. Nothing changed\. Draft kept\.$/); assert.deepEqual(await b.evaluate('posts.at(-1).vars'),{model:'x'}); assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=instance]").value'),'gate-1'); assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=vars]").value'),'{"model":"x"}'); // mos-0825 F1: a 200 without the view's definitions leaves the view as it was, keeps the draft and // says to read again, and Settings still draws. await b.evaluate(`replies.push([200,${JSON.stringify({revision:'d'.repeat(64),restart:true,instances:{'gate-1':{definition:'merge-gate',vars:{}}}})}]);true`); await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true'); await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Outcome unknown")'); assert.equal(await receipt(),'Outcome unknown. Read the role instances again before trying. Draft kept.'); assert.equal(await b.evaluate('posts.length'),4); assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=instance]").value'),'gate-1'); assert.equal(await b.evaluate('document.querySelectorAll("#agents-table tbody tr:not(.agent-editor)").length'),3); // An unread view is one section's warning, not the page's empty state, and offers no form. // The same URL with a hash would be a same-document navigation, so load it with another query. A // location.reload() from evaluate returned before the reload committed; the next wait then ran on the // old document and the reload killed it (row 172: "Inspected target navigated or closed" under load). current=null; await b.navigate(`http://127.0.0.1:${port}/?send=1&read=2#/settings/agents`); await wait('!!document.querySelector("#agents-title")?.parentElement.querySelector(".warn")'); assert.equal(await b.evaluate('document.querySelector("#agents-title").parentElement.querySelector(".warn").textContent'),'The agents could not be read, so there is nothing to act on. Read again to retry.'); assert.equal(await b.evaluate('document.querySelectorAll("#agents-title ~ .empty, .instance-form").length'),0); }finally{await b.close();await close(web);} });