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>
86 lines
12 KiB
JavaScript
86 lines
12 KiB
JavaScript
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)});
|