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 errors=[]; async function login(name){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('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('.day').first().waitFor();return page;} 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{ alice=await login('alice');bob=await login('bob'); // Isolate the workflow from the seeded examples and from other browser suites. const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.username==='bob').user.id};}); const team=await post(alice,'/api/workspaces',{name:'Cover UX '+Date.now()}); const endpoint=`/api/workspaces/${team.id}`; const start=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.addDays(a.today(),2);}); const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);let r=await a.api(endpoint+'/members','PUT',{revision:1,username:'bob',permissions:['view','request_overrides']});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}); 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;});