2026-09-24 14:35:05 +03:00
const { openSetup } = require ( './support/browser.cjs' );
2026-09-24 16:10:08 +03:00
2026-09-24 14:35:05 +03:00
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 ()=>{
2026-09-24 16:10:08 +03:00
const browser = await require ( './support/browser.cjs' ). launchBrowser ();
2026-09-24 14:35:05 +03:00
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/ ); assert . match ( await page . locator ( '.demo-note' ). innerText (), /alice[\s\S]*all four workspaces[\s\S]*bob[\s\S]*carol/ );
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 ( 'meguri-demo-only' ); 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 : 'meguri-demo-only' }}); 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 a separate account, leaving the examples untouched.
2026-10-07 15:30:31 +01:00
await page . getByRole ( 'button' ,{ name : 'Sign out' , exact : true }). click (); await page . getByRole ( 'button' ,{ name : 'New here? Create an account' , exact : true }). click (); const unique = Date . now (); await page . getByLabel ( 'Username' ,{ exact : true }). fill ( 'matrix-' + unique ); await page . getByLabel ( 'Display name' ,{ exact : true }). fill ( 'Matrix tester' ); await page . getByLabel ( 'Password' ,{ exact : true }). fill ( 'local-browser-test-password' ); await page . getByLabel ( 'Email address' ,{ exact : true }). fill ( 'browser-' + Date . now () + '-' + Math . random (). toString ( 16 ). slice ( 2 ) + '@example.com' ); await page . getByRole ( 'button' ,{ name : 'Create account' , exact : true }). click ();
2026-09-24 14:35:05 +03:00
await page . getByRole ( 'button' ,{ name : 'New workspace' , exact : true }). click (); await dialog (). getByLabel ( 'Name' ,{ exact : true }). fill ( 'Matrix ' + unique ); await save ();
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 ();
2026-10-07 15:30:31 +01:00
await page . getByRole ( 'button' ,{ name : 'Save checked changes' , exact : true }). click (); await page . waitForFunction ( rev => Alpine . $data ( document . body ). workspace . revision > rev , revision ); await checked ();
2026-09-24 14:35:05 +03:00
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 );
2026-10-07 15:30:31 +01:00
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 : 'Save 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 );
2026-09-24 14:35:05 +03:00
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 )});