- broker: the hire.raise answer, the inbox console preview and the trail read carry a
binding as {instance, change} only; the raise event keeps the full proposal the resolve reads.
- copy-launch status(): a copy whose role needs a service a copy cannot bind reads
copy-service-unbindable (new fixed status text) instead of a gitea binding step.
- cli instances: an unbound copy's line names its missing services.
- webui serve: the instances verb rebuilds each copy binding from the fixed status table.
- bus.js reads the binding change field only.
- browser tests: system Chromium starts with --disable-extensions; the A3 booted-bus test passes it.
99 lines
10 KiB
JavaScript
99 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {fixture} from './fixture.mjs';
|
|
import {mkdir} from 'node:fs/promises';
|
|
import {join} from 'node:path';
|
|
import {browser} from './browser.mjs';
|
|
import {BINDING_NEXT} from '../src/decision-http.mjs';
|
|
for(const width of [1440,390])test(`A4 Console hire start and narrowed confirmation at ${width}px`,{timeout:120000},async t=>{
|
|
const f=await fixture(),b=await browser({machine:false});t.after(async()=>{await b.close();await f.close();});
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{
|
|
const real=fetch;window.__posts=[];
|
|
const hire={id:'d1',kind:'hire',op:'hire',level:'org',name:'scout',route:{harness:'pi',model:'fixture-model'},flagged:[{field:'tools',value:'bash',reasons:['above-chief']}],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',digest:'a'.repeat(64),role:{tools:['read','bash'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[{service:'gitea',scopes:['read:repository']}],skills:[]},options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]};
|
|
const d={id:'d1',at:'2026-11-11T00:00:00.000Z',raised_by_role:'chief',raised_by_run:'chief-run',class:'gated',action:'role.hire',route_to:'human',question:'Hire scout?',options:hire.options,blocking:false,console:hire};
|
|
const json=v=>new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}});
|
|
window.fetch=async(url,init={})=>{
|
|
const p=new URL(url,location.href).pathname;
|
|
if(p==='/api/config')return json({sso:true,send:true,decide:true});
|
|
if(p==='/api/business')return json({business:{id:'demo',projects:[]}});
|
|
if(p==='/api/bus/inbox')return json({rows:[d]});
|
|
if(p==='/api/bus/trail')return json({rows:[{...d,table:'decisions'}]});
|
|
if(p==='/api/bus/agents'||p==='/api/bus/messages'||p==='/api/chat/projects'||p==='/api/chat/threads')return json({rows:[]});
|
|
if(p.startsWith('/api/decisions/')){
|
|
const body=JSON.parse(init.body);window.__posts.push({p,body});
|
|
if(p.endsWith('/templates'))return json({templates:[{name:'coder',sha256:'b'.repeat(64)}]});
|
|
if(p.endsWith('/start'))return json({id:'started',op:'resolved',choice:'yes'});
|
|
if(p.endsWith('/confirmation'))return json({id:'d1',kind:'hire',action:'answer',question:'Hire scout?',choice:body.choice,text:'Approve',flagged:[],credentialLines:[],expiresAt:hire.expiresAt,confirmation:'c'.repeat(64)});
|
|
return json({id:'d1',op:'resolved',choice:body.choice});
|
|
}
|
|
if(p==='/api/processing'||p==='/api/bus-status')return json({error:'unavailable'});
|
|
return real(url,init);
|
|
};
|
|
})()`});
|
|
const shots=async(state,selector)=>{if(!process.env.MOSAIC_UX_SHOTS)return;await mkdir(process.env.MOSAIC_UX_SHOTS,{recursive:true});for(const mode of ['light','dark']){await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(mode)};document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'start'})`);await b.screenshot(join(process.env.MOSAIC_UX_SHOTS,`hire-${state}-${width}-${mode}.png`));}};
|
|
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expr})return;await new Promise(r=>setTimeout(r,50));}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
|
|
await b.viewport(width,width===390?844:900);await b.navigate(f.base+'/#/inbox');await wait('document.querySelector("#hire-start-form")');
|
|
await b.evaluate(`document.querySelector('[data-hire-field="business"]').value='demo';document.querySelector('[data-hire-field="business"]').dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('[data-hire-load]').click()`);
|
|
await wait('document.querySelector("[data-hire-field=template] option[value=coder]")');
|
|
await shots('start','#hire-start');
|
|
await b.evaluate(`for(const [k,v] of Object.entries({name:'scout',template:'coder',harness:'pi',model:'fixture-model',reason:'Need a coder'})){const e=document.querySelector('[data-hire-field="'+k+'"]');e.value=v;e.dispatchEvent(new Event('input',{bubbles:true}));}document.querySelector('#hire-start-form').requestSubmit()`);
|
|
await wait('document.querySelector("#hire-start-status")?.textContent.includes("Recorded")');
|
|
let posts=await b.evaluate('window.__posts');assert.equal(posts.filter(x=>x.p.endsWith('/start')).length,1);assert.equal(posts.find(x=>x.p.endsWith('/start')).body.hire.role.sha256,'b'.repeat(64));
|
|
await b.navigate(f.base+'/#/inbox/d1');await wait('document.querySelector("#hire-adjust")');
|
|
assert.doesNotMatch(await b.evaluate("document.querySelector('#b-o').parentElement.textContent"),/mosaic decide/);
|
|
await b.evaluate("document.querySelector('#hire-adjust').open=true");await shots('adjust','#hire-adjust');
|
|
await b.evaluate(`document.querySelector('[data-hire-list="tools"][data-index="1"]').checked=false;document.querySelector('[data-hire-credential]').checked=false;document.querySelector('[data-decide="option-0"]').click()`);
|
|
await wait('document.querySelector("[data-decide=confirm]")');posts=await b.evaluate('window.__posts');const confirm=posts.find(x=>x.p.endsWith('/confirmation'));
|
|
assert.deepEqual(confirm.body.adjust.role.tools,['read']);assert.deepEqual(confirm.body.adjust.credentials,[]);
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'),true);await shots('confirm','#decide-section');
|
|
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
|
|
posts=await b.evaluate('window.__posts');assert.deepEqual(Object.keys(posts.find(x=>x.p.endsWith('/resolve')).body).sort(),['choice','confirmation']);
|
|
});
|
|
// #1873 CB5: a copy credential binding decision on its decision page. The inbox row carries the bus's
|
|
// summary, the copy and its change; the page names both and lists the computed scope lines, the
|
|
// confirmation says the change, and the receipt adds decision-http's fixed next step. Nothing besides
|
|
// the copy's instance reaches the page: a sentinel in any other field never shows, and an `after`
|
|
// that contradicts `change` (a bus older than the summary) never decides the line.
|
|
for(const change of ['bind','unbind'])test(`CB5 Console binding decision: ${change} confirmation and receipt`,{timeout:120000},async t=>{
|
|
const SENTINEL='cb5-sentinel-7f3a9c41d2e8b6a0';
|
|
const f=await fixture(),b=await browser({machine:false,args:['--disable-extensions']});t.after(async()=>{await b.close();await f.close();});
|
|
const after=change==='unbind'?{gitea:{scopes:['read:repository'],projects:['web'],slot:SENTINEL}}:null;
|
|
const binding={instance:'acme/researcher',change,copyId:SENTINEL,orgIncarnation:SENTINEL,definition:SENTINEL,before:{gitea:{grantId:SENTINEL}},after};
|
|
const hire={kind:'hire',flagged:[],credentialLines:[{service:'gitea',scope:'read:repository'}],expiresAt:'2099-01-01T00:00:00.000Z',binding,options:[{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]};
|
|
const d={id:'d2',at:'2026-11-12T00:00:00.000Z',raised_by_role:'jason',raised_by_run:null,class:'gated',action:'role.hire',route_to:'human',question:`${change} credentials for copy acme/researcher?`,options:hire.options,blocking:false,console:hire};
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{
|
|
const real=fetch;window.__posts=[];const d=${JSON.stringify(d)};
|
|
const json=v=>new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}});
|
|
window.fetch=async(url,init={})=>{
|
|
const p=new URL(url,location.href).pathname;
|
|
if(p==='/api/config')return json({sso:true,send:true,decide:true});
|
|
if(p==='/api/business')return json({business:{id:'acme',projects:[]}});
|
|
if(p==='/api/bus/inbox')return json({rows:[d]});
|
|
if(p==='/api/bus/trail')return json({rows:[{...d,table:'decisions'}]});
|
|
if(p==='/api/bus/agents'||p==='/api/bus/messages'||p==='/api/chat/projects'||p==='/api/chat/threads')return json({rows:[]});
|
|
if(p.startsWith('/api/decisions/')){
|
|
const body=JSON.parse(init.body);window.__posts.push({p,body});
|
|
if(p.endsWith('/confirmation'))return json({id:'d2',kind:'hire',action:'answer',question:d.question,choice:body.choice,text:'Approve',flagged:[],credentialLines:d.console.credentialLines,expiresAt:d.console.expiresAt,binding:{instance:'acme/researcher',change:${JSON.stringify(change)}},confirmation:'c'.repeat(64)});
|
|
return json({id:'d2',op:'resolved',choice:body.choice,next:${JSON.stringify(BINDING_NEXT[change])}});
|
|
}
|
|
if(p==='/api/processing'||p==='/api/bus-status')return json({error:'unavailable'});
|
|
return real(url,init);
|
|
};
|
|
})()`});
|
|
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expr})return;await new Promise(r=>setTimeout(r,50));}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
|
|
const text=()=>b.evaluate('document.body.innerText');
|
|
const line=change==='unbind'?'Unbind and revoke the credentials of copy acme/researcher.':'Bind credentials for copy acme/researcher.';
|
|
await b.viewport(1440,900);await b.navigate(f.base+'/#/inbox/d2');await wait('document.querySelector("#decide-section [data-binding-change]")');
|
|
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-binding-change]").dataset.bindingChange'),change);
|
|
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-binding-change]").textContent'),line);
|
|
assert.match(await b.evaluate('document.querySelector("#decide-section").innerText'),/gitea: read:repository/);
|
|
assert.equal(await b.evaluate('!!document.querySelector("#hire-adjust")'),false);
|
|
await b.evaluate('document.querySelector(\'[data-decide="option-0"]\').click()');await wait('document.querySelector("[data-decide=confirm]")');
|
|
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-binding-change]").textContent'),line);
|
|
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
|
|
assert.equal(await b.evaluate('document.querySelector("[data-binding-next]")?.textContent'),BINDING_NEXT[change]);
|
|
const posts=await b.evaluate('window.__posts');assert.deepEqual(Object.keys(posts.find(x=>x.p.endsWith('/resolve')).body).sort(),['choice','confirmation']);
|
|
assert.equal(JSON.stringify(posts).includes(SENTINEL),false);
|
|
const page=await text();assert.equal(page.includes(SENTINEL),false);assert.equal(page.includes(SENTINEL.slice(0,16)),false);
|
|
});
|