2026-09-24 16:10:08 +03:00
2026-10-07 13:28:43 +01:00
const { isolatedUser } = require ( './support/browser.cjs' );
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 errors = [];
2026-10-07 13:28:43 +01:00
async function login ( name , secret ){ const page = await browser . newPage ({ viewport : { width : 1440 , height : 1050 }, timezoneId : 'America/New_York' }); page . setDefaultTimeout ( 10000 ); page . on ( 'pageerror' , e => errors . push ( e . message )); await page . goto ( base ); await page . getByLabel ( 'Username' ,{ exact : true }). fill ( name ); await page . getByLabel ( 'Password' ,{ exact : true }). fill ( secret ); await page . getByRole ( 'button' ,{ name : 'Sign in' , exact : true }). click (); await page . waitForFunction (()=>{ const a = Alpine . $data ( document . body ); return a . ready && a . user &&! a . busy && a . workspace ;}); return page ;}
2026-09-24 14:35:05 +03:00
async function refresh ( page ){ await page . reload (); await page . locator ( '.day' ). first (). waitFor ();}
const dialog = p => p . getByRole ( 'dialog' );
const post = ( page , path , body )=> page . evaluate ( async ({ path , body })=>{ const a = Alpine . $data ( document . body ); return a . api ( path , 'POST' , body );},{ path , body });
let alice , bob ;
try {
2026-10-07 13:28:43 +01:00
// Sandbox 6 belongs to this suite: an administrator (Alice Morgan) and one employee (Bob Chen).
const sandbox = await isolatedUser ({ sandbox : 6 , prefix : 'cover-lead' , display : 'Alice Morgan' , employees : [ 'Bob Chen' ]});
const [ bobAccount ] = sandbox . staff , team = sandbox . workspace ;
alice = await login ( sandbox . username , sandbox . password ); bob = await login ( bobAccount . username , bobAccount . password );
const ids = await alice . evaluate (()=>{ const a = Alpine . $data ( document . body ); return { alice : a . user . id , bob : a . members . find ( m => m . user . display_name === 'Bob Chen' ). user . id };});
2026-09-24 14:35:05 +03:00
const endpoint = `/api/workspaces/ ${ team . id } ` ;
const start = await alice . evaluate (()=>{ const a = Alpine . $data ( document . body ); return a . addDays ( a . today (), 2 );});
2026-10-07 13:28:43 +01:00
const configured = await alice . evaluate ( async ({ endpoint , ids , start })=>{ const a = Alpine . $data ( document . body ); const r = ( await a . api ( endpoint )). workspace ; const doc = r . document ; doc . schedules = [{ id : crypto . randomUUID (), name : 'Weekly duty' , type : 'rotation' , participants : [ ids . alice , ids . bob ], anchor : start , shift_days : 7 , handover : '11:00:00' }]; return a . api ( endpoint + '/config' , 'PUT' ,{ revision : r . revision , document : doc });},{ endpoint , ids , start });
2026-09-24 14:35:05 +03:00
const url = base + '/?workspace=' + team . id ;
await alice . goto ( url ); await bob . goto ( url ); await bob . locator ( '.day' ). first (). waitFor ();
assert . equal ( await bob . getByRole ( 'button' ,{ name : 'Schedules' , exact : true }). isVisible (), false , 'Setup stays out of the daily flow' );
await bob . locator ( `[data-duty-date=" ${ start } "]` ). click ();
assert . equal ( await dialog ( bob ). getByRole ( 'button' ,{ name : 'Apply without approval' , exact : true }). count (), 0 );
await dialog ( bob ). getByLabel ( 'Comment (optional)' ,{ exact : true }). fill ( 'OPS-123 · flight' );
await dialog ( bob ). getByRole ( 'button' ,{ name : 'Offer to cover' , exact : true }). click (); await dialog ( bob ). waitFor ({ state : 'hidden' });
await refresh ( alice );
await alice . getByRole ( 'region' ,{ name : 'Needs your reply' }). getByText ( 'OPS-123 · flight' ,{ exact : true }). waitFor ();
await alice . screenshot ({ path : 'test-results/cover-inbox.png' , fullPage : true });
await alice . getByRole ( 'region' ,{ name : 'Needs your reply' }). getByRole ( 'button' ,{ name : 'Accept' , exact : true }). click ();
await alice . waitForFunction ( start =>{ const a = Alpine . $data ( document . body ); return a . doc . requests . some ( r => r . status === 'applied' && r . reason === 'OPS-123 · flight' );}, start );
await refresh ( bob ); assert . match ( await bob . locator ( `[data-duty-date=" ${ start } "]` ). innerText (), /Bob/ );
// A replaced day can be offered back and approved through the same calendar sheet.
await alice . locator ( `[data-duty-date=" ${ start } "]` ). click ();
await dialog ( alice ). getByLabel ( 'Comment (optional)' ,{ exact : true }). fill ( 'Correct earlier cover' );
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Offer to cover' , exact : true }). click ();
await dialog ( alice ). waitFor ({ state : 'hidden' });
await refresh ( bob );
assert . match ( await bob . locator ( `[data-duty-date=" ${ start } "]` ). innerText (), /Bob/ );
await bob . getByRole ( 'region' ,{ name : 'Needs your reply' }). getByRole ( 'button' ,{ name : 'Accept' , exact : true }). click ();
await bob . waitForFunction (()=> Alpine . $data ( document . body ). doc . requests . some ( r => r . reason === 'Correct earlier cover' && r . status === 'applied' ));
await refresh ( alice );
assert . match ( await alice . locator ( `[data-duty-date=" ${ start } "]` ). innerText (), /Alice/ );
const prior = await alice . evaluate (()=> Alpine . $data ( document . body ). doc . requests . find ( r => r . reason === 'OPS-123 · flight' ));
assert . equal ( prior . legs . length , 1 );
assert . equal ( prior . effective_legs . length , 0 );
// Owner asks for cover; the colleague accepts the exact request from the day sheet.
const second = await alice . evaluate ( start => Alpine . $data ( document . body ). addDays ( start , 1 ), start );
await alice . locator ( `[data-duty-date=" ${ second } "]` ). click ();
await dialog ( alice ). getByLabel ( 'Search colleagues' ). fill ( 'bob' );
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Bob Chen' , exact : true }). click ();
await dialog ( alice ). getByLabel ( 'Comment (optional)' ,{ exact : true }). fill ( 'Vacation' );
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Send request' , exact : true }). click (); await dialog ( alice ). waitFor ({ state : 'hidden' });
await refresh ( bob ); await bob . locator ( `[data-duty-date=" ${ second } "]` ). click ();
await dialog ( bob ). getByText ( 'Vacation' ,{ exact : true }). waitFor ();
await dialog ( bob ). getByRole ( 'button' ,{ name : 'Accept & cover' , exact : true }). click (); await dialog ( bob ). waitFor ({ state : 'hidden' });
// A lead sees the third action. Empty comments are accepted and force is audited.
const third = await alice . evaluate ( start => Alpine . $data ( document . body ). addDays ( start , 2 ), start );
await refresh ( alice ); await alice . locator ( `[data-duty-date=" ${ third } "]` ). click ();
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Apply without approval' , exact : true }). click ();
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Bob Chen' , exact : true }). click ();
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Apply now' , exact : true }). click (); await dialog ( alice ). waitFor ({ state : 'hidden' });
const forced = await alice . evaluate (()=> Alpine . $data ( document . body ). doc . requests . at ( - 1 )); assert . equal ( forced . status , 'applied' ); assert . equal ( forced . reason , '' ); assert . equal ( forced . forced_by , ids . alice );
// Exact endpoint UI checks ownership on the server before enabling save.
const fourth = await alice . evaluate ( start => Alpine . $data ( document . body ). addDays ( start , 3 ), start );
await alice . locator ( `[data-duty-date=" ${ fourth } "]` ). click (); await dialog ( alice ). getByRole ( 'button' ,{ name : 'Bob Chen' , exact : true }). click ();
const invalid = await alice . evaluate ( start => Alpine . $data ( document . body ). addDays ( start , 8 ), start );
await dialog ( alice ). getByLabel ( 'End date' ,{ exact : true }). fill ( invalid . split ( '-' ). reverse (). join ( '-' ));
await dialog ( alice ). locator ( '.validation-error' ). filter ({ hasText : 'no longer owns' }). waitFor (); assert ( await dialog ( alice ). getByRole ( 'button' ,{ name : 'Send request' , exact : true }). isDisabled ());
await dialog ( alice ). getByLabel ( 'End date' ,{ exact : true }). fill (( await alice . evaluate ( d => Alpine . $data ( document . body ). addDays ( d , 1 ), fourth )). split ( '-' ). reverse (). join ( '-' ));
await alice . waitForFunction (()=> Alpine . $data ( document . body ). canSendRequest );
await alice . setViewportSize ({ width : 390 , height : 844 }); await alice . screenshot ({ path : 'test-results/cover-mobile.png' , fullPage : true }); assert ( await alice . evaluate (()=> document . documentElement . scrollWidth <= innerWidth ));
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Cancel' , exact : true }). click ();
await alice . screenshot ({ path : 'test-results/cover-calendar-mobile.png' , fullPage : true });
await alice . setViewportSize ({ width : 1440 , height : 1050 });
await alice . getByRole ( 'button' ,{ name : 'Ask for cover' , exact : true }). click ();
assert . equal ( await dialog ( alice ). getByLabel ( 'Start time' ,{ exact : true }). inputValue (), await alice . evaluate (()=> Alpine . $data ( document . body ). doc . working_interval . start . slice ( 0 , 5 )));
assert . equal ( await alice . evaluate (()=>{ const a = Alpine . $data ( document . body ); return a . form . end_day === a . addDays ( a . form . start_day , 1 );}), true );
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Cancel' , exact : true }). click ();
const sixth = await alice . evaluate ( start => Alpine . $data ( document . body ). addDays ( start , 5 ), start );
await alice . locator ( '[data-duty-date="' + sixth + '"]' ). click (); await dialog ( alice ). getByRole ( 'button' ,{ name : 'Bob Chen' , exact : true }). click ();
await dialog ( alice ). getByLabel ( 'Start time' ,{ exact : true }). fill ( '13:00' ); await dialog ( alice ). getByLabel ( 'End date' ,{ exact : true }). fill ( sixth . split ( '-' ). reverse (). join ( '-' )); await dialog ( alice ). getByLabel ( 'End time' ,{ exact : true }). fill ( '15:00' );
await alice . waitForFunction (()=> Alpine . $data ( document . body ). canSendRequest ); await alice . screenshot ({ path : 'test-results/cover-exact-time.png' });
await dialog ( alice ). getByRole ( 'button' ,{ name : 'Apply without approval' , exact : true }). click (); await alice . waitForFunction (()=> Alpine . $data ( document . body ). canSendRequest ); await dialog ( alice ). getByRole ( 'button' ,{ name : 'Apply now' , exact : true }). click (); await dialog ( alice ). waitFor ({ state : 'hidden' });
const timed = await alice . evaluate (()=> Alpine . $data ( document . body ). doc . requests . at ( - 1 )); assert . equal ( Date . parse ( timed . legs [ 0 ]. end ) - Date . parse ( timed . legs [ 0 ]. start ), 7200000 ); assert . equal ( timed . status , 'applied' );
assert . deepEqual ( errors ,[]); console . log ( 'PASS: volunteer → owner acceptance, own-day request → one-click acceptance, optional action comments, privileged third action, live range errors, display timezone, mobile' );
} catch ( e ){ if ( alice ) await alice . screenshot ({ path : 'test-results/cover-failure.png' , fullPage : true }); console . error ( errors ); throw e ;} finally { await browser . close ();}
})(). catch ( e =>{ console . error ( e ); process . exitCode = 1 ;});