102 lines
9.3 KiB
JavaScript
102 lines
9.3 KiB
JavaScript
// 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='<img src=x onerror="window.injected=true">';
|
|
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);}
|
|
});
|