The click handler returned on busy, which refresh() holds for the whole /api/board read, so a click during a poll did nothing and the sign-out test waited 5 s for a sign-out that was never asked for. The row 165 test's location.reload() from an evaluate let the next wait start on the old document, and the reload killed it.
100 lines
9.4 KiB
JavaScript
100 lines
9.4 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`);
|
|
await wait('!!document.querySelector("#instances-table")');
|
|
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instances-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].map(c=>c.textContent))"),[
|
|
['chief','chief-of-staff','launcher','none'],['coder','coder','yes',JSON.stringify({note:hostile})],['assistant','assistant','no','none'],
|
|
]);
|
|
assert.equal(await b.evaluate('window.injected===true'),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.deepEqual(await b.evaluate("[...document.querySelectorAll('#instance-change-form [data-instance-field=instance] option')].map(o=>o.value)"),['chief','coder'],'an assistant changes in the Assistant section');
|
|
assert.match(await b.evaluate('document.querySelector("#instances-title").parentElement.textContent'),/Definitions that declare credentials, a hand edit: coder \(gitea\), reviewer \(gitea, vikunja\)\./);
|
|
assert.equal(await b.evaluate('[...document.querySelectorAll(".instance-form button,.instance-form input,.instance-form select,.instance-form textarea")].every(e=>e.disabled)'),true);
|
|
|
|
// 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`);
|
|
await wait('document.querySelector("#instance-add-form button")?.disabled===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 set('instance-change-form','instance','coder');
|
|
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','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("#instances-table tbody tr").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`);
|
|
await wait('!!document.querySelector("#instances-title")?.parentElement.querySelector(".warn")');
|
|
assert.equal(await b.evaluate('document.querySelector("#instances-title").parentElement.querySelector(".warn").textContent'),'The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#instances-title ~ .empty, .instance-form").length'),0);
|
|
}finally{await b.close();await close(web);}
|
|
});
|