const {openSetup}=require('./support/browser.cjs'); const {chromium}=require('playwright'); const assert=require('node:assert/strict'); const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000'; assert(['localhost','127.0.0.1'].includes(new URL(base).hostname)); (async()=>{ const browser=await chromium.launch({...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL || 'chrome'}),headless:true}); 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()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});}; const fmt=iso=>iso.split('-').reverse().join('-'); try { const unique=Date.now();await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click(); await page.getByLabel('Username',{exact:true}).fill('absence-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Absence tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click(); await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save(); await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save(); const dates=await page.evaluate(async()=>{ const app=Alpine.$data(document.body); for(const username of ['bob','carol']){await app.api(app.endpoint('/members'),'PUT',{revision:app.workspace.revision,username,permissions:['view','request_overrides']});await app.loadWorkspace();} const bob=app.directory.find(p=>p.username==='bob').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13); const rotation=crypto.randomUUID(); doc.schedules=[{id:rotation,name:'General',handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:start,shift_days:7},{id:crypto.randomUUID(),name:'Backup',handover:'09:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}]; for(let i=1;i<=9;i++)doc.schedules.push({id:crypto.randomUUID(),name:'Project '+i,handover:'11:00:00',type:'coverage',primary:app.user.id,secondary:bob,fallback:rotation}); doc.holidays=[{country:'CY',date:'2027-12-25',name:'Christmas'},{country:'CY',date:'2027-01-01',name:'New Year'},{country:'AM',date:'2027-01-01',name:'New Year'}]; await app.api(app.endpoint('/config'),'PUT',{revision:app.workspace.revision,document:doc});await app.loadWorkspace(); return {start,end,middle:app.addDays(start,6),next:app.addDays(start,7),revision:app.workspace.revision}; }); await openSetup(page,'Schedules'); const cy=page.locator('details.holiday-group').filter({has:page.locator('summary').filter({hasText:'Cyprus'})}); assert.equal(await cy.getAttribute('open'),null);assert.equal((await cy.locator('.badge').textContent()).trim(),'2'); assert(!(await cy.getByText('Christmas',{exact:true}).isVisible()));await cy.locator('summary').click();await cy.getByText('25-12-2027 · country calendar date',{exact:true}).waitFor(); await page.getByRole('button',{name:'+ Absence',exact:true}).click(); await dialog().getByLabel('First absent duty day',{exact:true}).fill(fmt(dates.start));await dialog().getByLabel('Last absent duty day (inclusive)',{exact:true}).fill(fmt(dates.end)); await dialog().getByText(/11 affected schedules · 0 replacement requests · 2 uncovered intervals/).waitFor(); assert.equal(await page.evaluate(()=>Alpine.$data(document.body).workspace.revision),dates.revision); const cell=dialog().locator('.impact-interval').first(); const nameBox=await cell.locator('strong').boundingBox(), datesBox=await cell.locator('small').boundingBox(); assert(datesBox.y>=nameBox.y+nameBox.height+4); await cell.screenshot({path:'test-results/absence-interval.png'}); const gap=await page.evaluate(()=>Alpine.$data(document.body).absenceResult.plan.rows.find(r=>r.status==='uncovered')); await dialog().getByRole('button',{name:'Choose a substitute',exact:true}).first().click(); await page.getByRole('region',{name:'Choose a person'}).locator('.picker-option').filter({hasText:'Bob Chen'}).click(); await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.absenceResult&&!a.absenceLoading;}); const quick=await page.evaluate(()=>Alpine.$data(document.body).form.ranges[0]); assert.equal(quick.schedule_id,gap.schedule_id); assert.equal(quick.start,gap.start_day);assert.equal(quick.end,await page.evaluate(end=>{const a=Alpine.$data(document.body);return a.dayKey(new Date(end),a.doc.timezone);},gap.end)); assert.equal(quick.start_time,'11:00:00');assert.equal(quick.end_time,'11:00:00'); await dialog().getByLabel('Start time',{exact:true}).fill('12:00'); await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.absenceResult&&!a.absenceLoading&&a.workspaceClock(a.absenceResult.plan.groups[0][0].start).startsWith('12:00');}); assert.equal(await page.evaluate(()=>Alpine.$data(document.body).absenceResult.plan.groups[0].length),1); await dialog().getByRole('button',{name:'Remove range',exact:true}).click(); await dialog().getByText(/11 affected schedules · 0 replacement requests · 2 uncovered intervals/).waitFor(); await dialog().getByRole('button',{name:'+ Replacement range',exact:true}).click(); const range=i=>dialog().locator('.absence-range').nth(i); await range(0).getByLabel('Range ends (exclusive)',{exact:true}).fill(fmt(dates.next)); await range(0).getByLabel('Start time',{exact:true}).fill('09:00'); await range(0).locator('.person-select').click();await page.getByRole('region',{name:'Choose a person'}).locator('.picker-option').filter({hasText:'Bob Chen'}).click(); await dialog().getByRole('button',{name:'+ Replacement range',exact:true}).click(); assert.equal(await range(1).getByLabel('Range starts',{exact:true}).inputValue(),fmt(dates.next)); await range(1).locator('.person-select').click();await page.getByRole('region',{name:'Choose a person'}).locator('.picker-option').filter({hasText:'Carol'}).click(); await dialog().getByText(/11 affected schedules · 2 replacement requests · 0 uncovered intervals/).waitFor(); await dialog().getByRole('button',{name:/Show all .* intervals/}).click();assert((await dialog().locator('.impact-table tbody tr').count())>8); await dialog().getByLabel('Range starts',{exact:true}).first().fill('31-02-2027'); await dialog().getByText('Complete the dates and choose a person for each replacement range.',{exact:true}).waitFor();assert(await dialog().getByRole('button',{name:'Save changes',exact:true}).isDisabled()); await dialog().getByLabel('Range starts',{exact:true}).first().fill(fmt(dates.start)); await dialog().getByLabel('Apply replacements without confirmation').check();await dialog().getByLabel('Comment (optional)',{exact:true}).fill('Agreed vacation coverage'); await dialog().getByText(/11 affected schedules · 2 replacement requests · 0 uncovered intervals/).waitFor(); await page.screenshot({path:'test-results/absence-plan.png',fullPage:true}); await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/absence-plan-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await save(); const result=await page.evaluate(()=>JSON.parse(JSON.stringify(Alpine.$data(document.body).doc))); assert.equal(result.absences.length,1);assert.equal(result.requests.length,2);assert(result.requests.every(r=>r.status==='applied' && r.legs.length===2));assert.deepEqual(errors,[]); console.log('PASS: collapsed country holidays, DD-MM-YYYY display/input, immediate invalid-date feedback, server-only absence preview, two bulk replacement ranges, automatic project secondary, forced application, mobile summary'); } catch(error){console.error(errors);console.error(await page.locator('[role=alert]').textContent());await page.screenshot({path:'test-results/absence-failure.png',fullPage:true});throw error;} finally{await browser.close();} })().catch(error=>{console.error(error);process.exitCode=1;});