Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model

NEXT predates the access work, so the identity layer follows master: access comes from
configured groups, instance administrators replace the site administration, and accounts are
created by SSO or by an administrator. Kept from NEXT: manual publication with the Review
changes panel and shared generator drafts, portable import/export (meguri/v2), the layered
generation map with binding provenance, JSM provider changes, contact/approval semantics and
the account-owned Person. Dropped: site administration, local sign-up, and the mandatory
unique email with its contact/account migrations. The manual-publication migration is
renumbered m_0010 after m_0009_access.

Accounts without an email still get a Person in every workspace they reach, a changed sign-in
email updates the account's person, and an account that loses access keeps its linked person so
schedules stay valid. Instance administrators may use Sync and shared drafts.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-07 16:06:10 +01:00
commit 38105c65c2
57 files changed
+6890 -431

No files matched your search

+84 -10
View File
@@ -1,13 +1,87 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true})Line truncated
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=false;});
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=true;a.generationDraft.variables.find(v=>v.name==='projects').node.entries[0].entries.find(n=>n.key==='responsible').entries[0].value='not-an-email';});
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);assert(await d.locator('.map-status-error').count());await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();assert.equal(await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).workspace.revision}),revision);
await page.getByLabel('Workspace',{exact:true}).selectOption({label:'Regional Platform'});await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Service · Iteration · 4 passes'});const rows=await page.evaluate(()=>Alpine.$data(document.body).mapRows());const filtered=rows.findIndex(r=>r.status==='filtered');assert(filtered>=0);await d.getByLabel('Iteration pass',{exact:true}).selectOption(String(filtered));await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===0);await page.screenshot({path:'test-results/generation-map-filtered.png'});await d.getByRole('button',{name:'Close dialog',exact:true}).click();
assert.deepEqual(errors,[]);console.log('PASS generation map, actual iteration outputs, pass filtering, group expansion, zoom, source editor, mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1});
const fs=require('node:fs');
const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{
const browser=await launchBrowser();
try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const tester=await isolatedUser({sandbox:7,prefix:'map-origin',display:'Path tester'});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
await page.evaluate(async()=>{
const a=Alpine.$data(document.body),doc=structuredClone(Alpine.raw(a.doc));
const manual={id:crypto.randomUUID(),name:'Manual fallback',type:'rotation',participants:[a.user.id],anchor:a.today(),handover:'11:00:00',shift_days:7};doc.schedules.push(manual);
await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();
const projects=a.newNode('list');for(const name of ['cse','opd']){const row=a.newNode('dictionary'),owners=a.newNode('list',null,'owners');owners.entries=[a.newNode('person',a.user.id)];row.entries=[a.newNode('text',name,'name'),owners];projects.entries.push(row);}
const v={id:crypto.randomUUID(),name:'projects',node:projects},later={id:crypto.randomUUID(),name:'later',node:a.newNode('text','Later schedule')};
a.generationDraft.variables=[later,v];
const loop={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:v.id,path:[]}};
const block=a.newBlock('priority');block.fields.name={kind:'item',iteration:loop.id,path:['name'],prefix:'',suffix:' daytime'};block.fields.participants={kind:'item',iteration:loop.id,path:['owners']};block.fields.fallback=a.literal(manual.id);
const policy=a.newBlock('escalation');policy.fields.name={kind:'item',iteration:loop.id,path:['name'],suffix:' escalation'};policy.steps[0].recipient={kind:'output',output:block.id};
const route=a.newBlock('route');route.fields.name={kind:'item',iteration:loop.id,path:['name'],suffix:' route'};route.fields.escalation={kind:'output',output:policy.id};
const second=a.newBlock('rotation');second.fields.name={kind:'variable',variable:later.id,path:[]};
a.generationDraft.rules=[{id:crypto.randomUUID(),name:'Project owners',enabled:true,iterations:[loop],when:null,blocks:[block,policy,route]},{id:crypto.randomUUID(),name:'Later generator',enabled:true,iterations:[],when:null,blocks:[second]}];
await a.openGenerationMap();
});
await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);
const map=page.locator('dialog.generation-map'),canvas=map.locator('.map-canvas');
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).mapSnapshot.report.issues),[]);
const layout=await page.evaluate(()=>Alpine.$data(document.body).mapNodes.map(n=>({id:n.id,lane:n.lane,title:n.title,x:n.position.x,y:n.position.y,manual:n.manual,step:n.step})));
assert(layout.find(n=>n.title==='projects').x<layout.find(n=>n.title==='later').x);
assert(Math.max(...layout.filter(n=>n.lane===0).map(n=>n.y))<Math.min(...layout.filter(n=>n.lane===1).map(n=>n.y)));
assert(Math.max(...layout.filter(n=>n.lane===1).map(n=>n.y))<Math.min(...layout.filter(n=>n.lane===2).map(n=>n.y)));
assert(layout.some(n=>n.manual&&n.title==='Manual fallback'));
const shapes=await canvas.evaluate(el=>{const cy=el._cyreg.cy;return ['variable','rule','output'].map(k=>cy.nodes('[kind="'+k+'"]').first().style('shape'));});
assert.equal(new Set(shapes).size,3);
assert.equal(await canvas.evaluate(el=>new Set(['schedule','escalation','route'].map(k=>el._cyreg.cy.nodes('[resourceKind="'+k+'"]').first().style('background-color'))).size),3);
const address=map.locator('.map-source-addresses .map-address').filter({has:page.getByRole('button',{name:'name',exact:true})});
await address.getByRole('button',{name:'name',exact:true}).click();
const probe=page.locator('.map-probe');await probe.waitFor();
assert((await probe.innerText()).includes('cse'));assert((await probe.innerText()).includes('opd'));
const chain=probe.locator('.map-probe-chain');
assert.equal(await chain.getByRole('button',{name:'View source',exact:true}).count(),0);
await chain.getByRole('button',{name:'Project.value',exact:true}).click();
assert((await probe.locator('.map-probe-example').first().innerText()).includes('Path tester'));
await chain.getByRole('button',{name:'name',exact:true}).click();
assert(!(await probe.locator('.map-probe-example').first().innerText()).includes('Path tester'));
assert.equal(await chain.locator('[aria-current="step"]').innerText(),'name');
assert((await chain.getByRole('button',{name:'name',exact:true}).evaluate(el=>getComputedStyle(el).fontFamily)).includes('monospace'));
await probe.getByRole('button',{name:'Done',exact:true}).click();
const resource=map.locator('.map-resource').filter({has:page.locator('.map-resource-name').filter({hasText:'cse daytime'})});
await resource.locator('.map-resource-name button').filter({hasText:'cse'}).click();await probe.waitFor();
assert((await probe.locator('.map-address').textContent()).includes('Project.value.name'));
await probe.locator('.map-probe-actions').getByRole('button',{name:'View source',exact:true}).click();
await probe.waitFor({state:'hidden'});
await resource.locator('summary').click();
await resource.getByRole('button',{name:'Path tester',exact:true}).click();await probe.waitFor();
assert((await probe.locator('.map-address').textContent()).includes('Project.value.owners'));
await probe.locator('.map-probe-chain').getByRole('button',{name:'var.projects',exact:true}).click();
await probe.getByRole('button',{name:'Back',exact:true}).click();
assert((await probe.locator('.map-address').textContent()).includes('Project.value.owners'));
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/map-provenance-desktop.png'});
await probe.getByRole('button',{name:'Done',exact:true}).click();
const policyResource=map.locator('.map-resource').filter({has:page.locator('.map-resource-name').filter({hasText:'cse escalation'})});
await policyResource.locator('summary').click();
await policyResource.getByRole('button',{name:'cse daytime',exact:true}).click();
await probe.locator('.map-probe-fields').getByRole('button',{name:'Name',exact:true}).click();
assert((await probe.innerText()).includes('cse daytime'));
await probe.getByRole('button',{name:'Done',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.mapSelect(a.mapNodes.find(n=>n.manual).id);});
await map.getByText('Manual resources · 1',{exact:true}).waitFor();
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.mapSelect(a.mapNodes.find(n=>n.kind==='iteration').id);a.mapToggle();});
assert(await page.evaluate(()=>Alpine.$data(document.body).mapNodes.some(n=>n.id.startsWith('o:'))));
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).mapAddressParts('{ var["a.b"].owners[1] }').map(p=>p.text)),['var["a.b"]','.','owners[1]']);
await map.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});
await page.setViewportSize({width:390,height:844});await map.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-mobile.png'});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
await page.setViewportSize({width:1280,height:900});await page.mouse.click(2,2);await map.waitFor({state:'hidden'});
assert.deepEqual(errors,[]);
console.log('PASS: layers, first-use ordering, shapes, manual resources, provenance segments, people, history, groups, mobile and backdrop');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});