Files
meguri/tests/browser-generation.cjs
T
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00

86 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const {openSetup,isolatedUser,signIn,demoPassword}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
fs.mkdirSync('test-results',{recursive:true});
(async()=>{
const browser=await require('./support/browser.cjs').launchBrowser();
const page=await browser.newPage({viewport:{width:1440,height:1100}});page.setDefaultTimeout(10000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const dialog=()=>page.getByRole('dialog');
const save=async(name='Save changes')=>{await dialog().getByRole('button',{name,exact:true}).click();await dialog().waitFor({state:'hidden'});};
const checked=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult && !a.generationLoading && a.generationResult.key===JSON.stringify(a.generationPayload());});
try {
await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);for(const [name,role] of [['admin','Instance administrator'],['alice','Workspace administrator'],['bob','Employee']])assert.match(await page.locator(`[data-demo-account=${name}]`).innerText(),new RegExp(role));assert.equal((await page.locator('[data-demo-password]').innerText()).trim(),await demoPassword());
await page.screenshot({path:'test-results/demo-login.png',fullPage:true});
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.waitForFunction(()=>Alpine.$data(document.body).workspace);
const demo=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {matrix:a.directoryState.workspaces.find(t=>t.name==='Acme Engineering'),support:a.directoryState.workspaces.find(t=>t.name==='Customer Support')};});
assert(demo.matrix && demo.support);assert.notEqual(demo.matrix.id,demo.support.id);
await page.getByLabel('Workspace',{exact:true}).selectOption(demo.matrix.id);await openSetup(page,'Variables & rules');await checked();
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);
const matrix=await page.evaluate(()=>Alpine.$data(document.body).doc);
assert.deepEqual(matrix.generation.variables.map(v=>v.name),['projects','on_call']);
assert.equal(await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.nodeSummary(a.generationDraft.variables.find(v=>v.name==='projects').node);}), 'Payments, Search, Billing, Identity…');
for(const project of matrix.generation.variables[0].node.entries){
const name=project.entries.find(n=>n.key==='name').value;
const owners=project.entries.find(n=>n.key==='responsible').entries.map(n=>n.value);
const schedule=matrix.schedules.find(s=>s.name===name+' · owners');
assert.deepEqual(schedule.participants,owners);
assert(matrix.routes.some(r=>r.name===name+' · working hours' && r.conditions.some(c=>c.value==='project:'+name)));
}
assert.equal(matrix.schedules.filter(s=>s.type==='priority').length,6);assert.equal(matrix.schedules.find(s=>s.name==='Time off').shift_days,7);assert.equal(matrix.schedules.find(s=>s.name==='Time off').handover,'11:00:00');
assert.equal(matrix.routes.filter(r=>r.window).length,6);assert.equal(matrix.routes.at(-1).window,null);
await page.screenshot({path:'test-results/matrix-demo.png',fullPage:true});
await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();await checked();await page.screenshot({path:'test-results/matrix-data-editor.png',fullPage:true});
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/matrix-data-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1440,height:1100});await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
await page.getByLabel('Workspace',{exact:true}).selectOption(demo.support.id);await checked();
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).doc.generation.rules[1].name),'Daily queue coverage');
await openSetup(page,'Escalations & routing');await page.screenshot({path:'test-results/support-demo.png',fullPage:true});
// Distinct members cannot read the other example's team, even with its URL.
for(const [username,denied] of [['bob',demo.support],['dave',demo.support]]){
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:await require('./support/browser.cjs').demoPassword()}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
}
// Mutating UI checks use Sandbox 5 and a separate administrator, leaving the demo examples untouched.
await page.getByRole('button',{name:'Sign out',exact:true}).click();
const tester=await isolatedUser({sandbox:5,prefix:'matrix',display:'Matrix tester'});
await signIn(page,tester.username,tester.password);await page.getByLabel('Workspace',{exact:true}).waitFor();
await openSetup(page,'Variables & rules');
await page.evaluate(()=>{const a=Alpine.$data(document.body),projects={id:crypto.randomUUID(),name:'projects',node:a.newNode('list')},responsible={id:crypto.randomUUID(),name:'responsible',node:a.newNode('dictionary')};for(const name of ['A','B']){projects.node.entries.push(a.newNode('text',name));const entry=a.newNode('list',null,name);entry.entries=['alice@example.com','bob@example.com'].map(email=>a.newNode('person',email));responsible.node.entries.push(entry);}const iteration={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:projects.id,path:[]}},block=a.newBlock('rotation');block.fields.name={kind:'item',iteration:iteration.id,path:[],suffix:' on call'};block.fields.participants={kind:'lookup',variable:responsible.id,key:{kind:'item',iteration:iteration.id,path:[]},path:[]};a.generationDraft.variables.push(projects,responsible);a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Project rotations',enabled:true,iterations:[iteration],when:null,blocks:[block]});a.generationChanged();});await checked();
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationCount('create')),2);
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
await page.getByRole('button',{name:'Edit variable responsible',exact:true}).click();await dialog().getByLabel('Dictionary key').first().fill('Missing');await checked();await dialog().locator('.modal-footer:visible .validation-toggle').click();assert.match(await dialog().locator('.validation-details').innerText(),/key ‘A’/);
assert.equal(await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).workspace.revision;}),revision);
await dialog().getByLabel('Dictionary key').first().fill('A');await checked();await save('Use in draft');await checked();
await page.getByRole('button',{name:'Edit rule Project rotations',exact:true}).click();await dialog().getByLabel('Shift length (days) · Fixed value',{exact:true}).fill('0');await checked();
await dialog().evaluate(el=>el.scrollTop=0);const status=dialog().locator('.modal-footer:visible .validation-control');assert.match(await status.innerText(),/Issues to resolve/);const box=await status.boundingBox();assert(box && box.y>=0 && box.y+box.height<=1100,'Validation stays in the viewport');
if(await status.locator('.validation-toggle').getAttribute('aria-expanded')==='false')await status.locator('.validation-toggle').click();assert.match(await status.locator('.validation-details').innerText(),/shift_days/);
await dialog().getByRole('button',{name:'Bind Shift length (days)',exact:true}).click();await dialog().getByLabel('Fixed number',{exact:true}).waitFor();assert.equal(await dialog().getByLabel('Rule name',{exact:true}).isVisible(),true,'The value picker preserves the constructor');
await dialog().getByLabel('Fixed number',{exact:true}).fill('1');await dialog().getByRole('button',{name:'Done',exact:true}).click();await dialog().getByLabel('Rule name',{exact:true}).waitFor();await checked();assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await page.screenshot({path:'test-results/generation-rule-editor.png',fullPage:true});await save('Use in draft');await checked();
await page.getByRole('button',{name:'Apply checked changes',exact:true}).click();await page.waitForFunction(rev=>Alpine.$data(document.body).workspace.revision>rev,revision);await checked();
const ids=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.map(s=>s.id));assert.equal(ids.length,2);assert.equal(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules[0].shift_days),1);
await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();await dialog().getByLabel('Variable name',{exact:true}).fill('services');await save('Use in draft');await checked();await page.getByRole('button',{name:'Apply checked changes',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).busy);await page.reload();await openSetup(page,'Variables & rules');await checked();assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.map(s=>s.id)),ids);
await openSetup(page,'Schedules');assert.equal(await page.locator('[data-resource-kind="schedules"] .source-rule-link').count(),2);
// The guided editor resolves the ordinary project/dictionary case without opening a binding editor.
await page.getByRole('button',{name:'+ Generation rule',exact:true}).click();await checked();
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).form.iterations.length),0);
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).form.blocks.length),0);
await dialog().getByLabel('Rule name',{exact:true}).fill('Explicit iteration');
await dialog().getByRole('button',{name:'+ Iteration',exact:true}).click();
await dialog().getByLabel('Add output block',{exact:true}).selectOption('priority');
const lookup=await page.evaluate(()=>Alpine.$data(document.body).sourceOptions('list').find(o=>o.kind==='lookup').id);
await dialog().getByLabel('Responsible people, in priority order',{exact:true}).selectOption(lookup);
assert.equal(await dialog().locator('.binding-editor').count(),0);
// Iterated generated instances are not offered as fixed fallback targets; choose an explicit generated rotation.
await dialog().getByLabel('Add output block',{exact:true}).selectOption('rotation');
const backup=await page.evaluate(()=>Alpine.$data(document.body).form.blocks.at(-1).id);
await dialog().getByLabel('Fallback schedule',{exact:true}).selectOption('output:'+backup);await checked();
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/generation-guided-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1440,height:1100});await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
await page.locator('[data-resource-kind="schedules"] > summary').first().click();await page.locator('[data-resource-kind="schedules"] .source-rule-link').first().click();assert.equal(await dialog().getByLabel('Rule name',{exact:true}).inputValue(),'Project rotations');await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
assert.deepEqual(errors,[]);console.log('PASS: four isolated demo workspaces, minimal login guide, country-aware matrix data, live dictionary errors, no preview writes, binding editor, atomic apply, stable IDs after rename/reload, generated source links, desktop/mobile');
}catch(e){await page.screenshot({path:'test-results/generation-failure.png',fullPage:true});console.error('Page errors:',errors);throw e;}
finally{await browser.close();}
})().catch(e=>{console.error(e);process.exit(1)});