Init
Checks / test (push) Failing after 12m20s

This commit is contained in:
ab committed 2026-09-24 14:35:05 +03:00
commit a47c4a0398
96 files changed
+25721

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
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;});
+12
View File
@@ -0,0 +1,12 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{
for(const [user,names] of [['alice',['Acme Engineering','Customer Support','Regional Platform','Commerce Operations']],['bob',['Acme Engineering','Regional Platform']],['carol',['Acme Engineering','Customer Support','Commerce Operations']],['dave',['Acme Engineering']],['erin',['Acme Engineering']]]){
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace);
const teams=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces);assert.deepEqual(teams.map(t=>t.name).filter(n=>['Acme Engineering','Customer Support','Regional Platform','Commerce Operations'].includes(n)).sort(),names.sort());
if(user==='alice'){const schedules=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules);assert.equal(schedules.filter(s=>s.type==='priority').length,6);assert(schedules.filter(s=>s.type==='priority').every(s=>s.participants.length>=2&&s.participants.length<=3));assert.equal(schedules.find(s=>s.type==='rotation').participants.length,5);}
if(user==='alice')for(const name of ['Regional Platform','Commerce Operations']){
const team=teams.find(t=>t.name===name);await page.goto(`http://127.0.0.1:3000/?view=configuration&workspace=${team.id}`);await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert(await page.locator('.resource-row.generated-resource').count()>=10);await page.getByRole('button',{name:name==='Regional Platform'?'Edit rule Region → enabled services':'Edit rule Product → service tier',exact:true}).click();await page.getByRole('dialog').waitFor();await page.screenshot({path:`test-results/demo-${team.name.startsWith('Regional')?'regional':'tiers'}.png`,fullPage:true});await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();
}
assert.deepEqual(errors,[]);await page.close();
}console.log('PASS four demo workspaces, advanced previews/editors, scoped Alice/Bob/Carol access');
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1
View File
@@ -0,0 +1 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+34
View File
@@ -0,0 +1,34 @@
// Read-only draft checks: actual values remain visible alongside server type errors.
const {chromium}=require('playwright'),assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({channel:process.env.MEGURI_BROWSER_CHANNEL||'chrome',headless:true});
try{
const page=await browser.newPage({viewport:{width:1280,height:950}}),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.MEGURI_URL||'http://127.0.0.1:3000/?view=configuration');
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.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body),b=a.form.blocks.find(b=>b.kind==='escalation');a.editBinding(b.fields.repeat_count ||= a.literal(0),'number','Repeat count');});
const preview=page.getByRole('textbox',{name:'Value preview',exact:true});await preview.waitFor();assert.equal(await preview.inputValue(),'0');
await page.getByLabel('Value source',{exact:true}).selectOption('variable');
const projects=await page.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='projects').id);
await page.getByLabel('Binding variable',{exact:true}).selectOption(projects);
await page.waitForFunction(()=>document.querySelector('.binding-value-preview textarea')?.value.includes('Payments'));
assert.deepEqual(JSON.parse(await preview.inputValue()),['Payments','Search','Billing','Identity','Data platform','Developer portal']);
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading && a.generationResult?.issues.some(i=>i.message.includes('repeat_count'));});
await page.screenshot({path:'test-results/binding-value-preview.png'});
await page.getByLabel('Binding variable',{exact:true}).selectOption('');await page.waitForFunction(()=>document.querySelector('.binding-value-preview textarea')?.value==='');
await page.getByRole('button',{name:'Done',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();
await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editBinding(a.form.blocks[0].fields.participants,'list','Owners');});
await page.waitForFunction(()=>document.querySelector('.binding-value-preview textarea')?.value.includes('Dave Wilson'));
const people=JSON.parse(await preview.inputValue());assert.equal(people.length,3);assert.equal(people[0].name,'Alice Morgan');assert.equal(people[0].username,'alice');assert.equal(people[1].name,'Bob Chen');
const result=await page.evaluate(()=>{const a=Alpine.$data(document.body),id=a.people.find(p=>p.username==='alice').id,unknown=crypto.randomUUID(),value={nested:[id,a.doc.schedules[0].id,unknown],number:7,flag:false};const before=JSON.stringify(value),draft=JSON.stringify(a.generationPayload());return {pretty:a.previewValue(value),unknown,unchanged:before===JSON.stringify(value)&&draft===JSON.stringify(a.generationPayload())};});assert(result.unchanged);assert.equal(result.pretty.nested[0].name,'Alice Morgan');assert.equal(result.pretty.nested[1].type,'schedule');assert.equal(result.pretty.nested[2],result.unknown);assert.equal(result.pretty.number,7);assert.equal(result.pretty.flag,false);
assert((await page.locator('.binding-value-preview small').innerText()).includes('Payments'));
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/binding-value-preview-mobile.png'});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);
console.log('PASS: live formatted values, server type errors, missing-value clearing, iteration context, mobile');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+108
View File
@@ -0,0 +1,108 @@
const {openSetup}=require('./support/browser.cjs');
// End-to-end coverage for scheduling contacts, country imports, working hours, and display timezones.
const {chromium}=require('playwright');
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(['localhost','127.0.0.1'].includes(new URL(base).hostname));
fs.mkdirSync('test-results',{recursive:true});
(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},timezoneId:'America/Los_Angeles'});
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 state=()=>page.evaluate(()=>{const app=Alpine.$data(document.body);return JSON.parse(JSON.stringify({workspace:app.workspace,user:app.user,endpoint:app.endpoint(),displayZone:app.displayZone}));});
try {
const label='Calendars '+Date.now();
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('calendars-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Calendar 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}).waitFor();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
await openSetup(page,'Workspace settings');
await page.getByRole('button',{name:'+ Add teammate'}).click();await dialog().getByLabel('Registered username').fill('bob');await save();
await openSetup(page,'Schedules');
await page.locator('.schedule-create > summary').click();
await page.getByRole('button',{name:'+ Rotation',exact:true}).click();
await dialog().getByLabel('Schedule name').fill('Distributed rotation');
await dialog().getByLabel('Handover time').fill('09:00');
await dialog().getByLabel('Remove participant').first().click();
await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click();
const picker=()=>page.getByRole('region',{name:'Choose a person'});
await picker().getByLabel('Search workspace users or enter email').fill('Bob');
await picker().locator('.picker-option').filter({hasText:'Bob Chen'}).click();
await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click();
await picker().getByLabel('Search workspace users or enter email').fill('never-logged-in@example.com');
assert.equal(await picker().getByLabel('Country',{exact:true}).inputValue(),'CY');
await picker().getByLabel('Name (optional)',{exact:true}).fill('Ani in Armenia');
await picker().getByLabel('Country',{exact:true}).selectOption('AM');
await picker().getByRole('button',{name:'Add never-logged-in@example.com',exact:true}).click();
await page.screenshot({path:'test-results/rotation-people.png',fullPage:true});await save();
let current=await state();assert.equal(await page.getByLabel('Workspace',{exact:true}).inputValue(),current.workspace.id);const contact=current.workspace.document.people.find(p=>p.email==='never-logged-in@example.com');
assert.equal(contact.user_id,null);assert.equal(contact.country,'AM');assert.equal(current.workspace.document.schedules[0].handover,'09:00:00');
await page.getByRole('button',{name:'+ Absence',exact:true}).click();
await dialog().locator('.person-select').click();
await picker().getByLabel('Search workspace users or enter email').fill('absence-only@example.com');
await picker().getByRole('button',{name:'Add absence-only@example.com',exact:true}).click();await save();
current=await state();assert(current.workspace.document.absences.some(a=>a.user_id===current.workspace.document.people.find(p=>p.email==='absence-only@example.com').id));
await page.locator('#public-holidays').evaluate(el=>el.open=true);await page.getByRole('button',{name:'Import CSV',exact:true}).click();
const csv='country,date,name\nCY,2027-01-01,New Year\nAM,2027-01-01,"New Year, holiday"';
const before=current.workspace.revision;
await dialog().getByLabel('Holiday CSV').fill(csv);
await dialog().getByText('New holidays: 2',{exact:true}).waitFor();
let remote=await page.evaluate(async endpoint=>fetch(endpoint).then(r=>r.json()),current.endpoint);
assert.equal(remote.workspace.revision,before,'Preview must not write');assert.equal(remote.workspace.document.holidays.length,0);
await dialog().getByLabel('Holiday CSV').fill(csv+'\nXX,2027-01-01,Invalid country');
await dialog().locator('.validation-error').waitFor();assert(await dialog().getByRole('button',{name:'Import new holidays'}).isDisabled());
await dialog().getByLabel('Holiday CSV').fill(csv);await dialog().getByText('New holidays: 2',{exact:true}).waitFor();
await page.screenshot({path:'test-results/holiday-import.png',fullPage:true});
await dialog().getByRole('button',{name:'Import new holidays'}).click();await dialog().waitFor({state:'hidden'});
await page.locator('.holiday-group summary').filter({hasText:'Cyprus'}).waitFor();await page.locator('.holiday-group summary').filter({hasText:'Armenia'}).waitFor();
await page.locator('#public-holidays').evaluate(el=>el.open=true);await page.getByRole('button',{name:'Import CSV',exact:true}).click();
await dialog().getByLabel('Holiday CSV').fill(csv);await dialog().getByText('2 already saved',{exact:true}).waitFor();assert(await dialog().getByRole('button',{name:'Import new holidays'}).isDisabled());
await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
await openSetup(page,'Workspace settings');
await page.getByRole('button',{name:'Edit working interval'}).click();
await dialog().getByLabel('Workspace IANA timezone').fill('Asia/Yerevan');
await dialog().getByLabel('Working day starts').fill('10:00');await dialog().getByLabel('Working day ends').fill('18:00');await save();
current=await state();assert.equal(current.workspace.document.timezone,'Asia/Yerevan');assert.equal(current.workspace.document.working_interval.start,'10:00:00');
await openSetup(page,'Escalations & routing');await page.getByRole('button',{name:'+ Escalation policy',exact:true}).click();
await dialog().getByLabel('Policy name').fill('Primary then backup');
await dialog().getByRole('button',{name:'+ Escalation step'}).click();await save();
await page.getByRole('button',{name:'+ Routing rule',exact:true}).click();
await dialog().getByLabel('Rule name').fill('Production work hours');
await dialog().getByLabel('Escalation policy').selectOption({label:'Primary then backup'});
await dialog().getByRole('button',{name:'+ AND condition'}).click();await dialog().getByLabel('Value',{exact:true}).fill('env:prod');
await dialog().getByLabel('Only during working hours').check();
await dialog().getByText(/Mon, Tue, Wed, Thu, Fri · 10:00–18:00 · Asia\/Yerevan/).waitFor();
assert.equal(await dialog().locator('input[type=time]').count(),0,'Working interval is edited at workspace level');
await page.screenshot({path:'test-results/routing-editor.png',fullPage:true});await save();
await page.screenshot({path:'test-results/policy-flows.png',fullPage:true});
current=await state();const schedule=JSON.stringify(current.workspace.document.schedules),revision=current.workspace.revision;
assert.equal(current.displayZone,'America/Los_Angeles','Follow browser timezone by default');
await page.locator('.account-button').click();
assert.equal(await dialog().getByLabel('Country of work').inputValue(),'CY');
assert((await dialog().getByLabel('Country of work').locator('option').count())>=249);
await dialog().getByLabel('Display timezone',{exact:true}).fill('Asia/Tokyo');await save();
current=await state();assert.equal(current.displayZone,'Asia/Tokyo');assert.equal(current.workspace.revision,revision);assert.equal(JSON.stringify(current.workspace.document.schedules),schedule);
await page.getByRole('link', { name: 'Calendar',exact:true}).click();await page.locator('.day-clock').first().filter({hasText:'14:00 GMT+9'}).waitFor();
await page.locator('.account-button').click();await dialog().getByRole('button',{name:'Use browser timezone'}).click();await save();
current=await state();assert.equal(current.displayZone,'America/Los_Angeles');assert.equal(current.workspace.revision,revision);
await openSetup(page,'Schedules');await page.locator('#public-holidays').evaluate(el=>el.open=true);await page.getByRole('button',{name:'Import CSV',exact:true}).click();
await dialog().getByLabel('Holiday CSV').fill(csv+'\nCY,2027-12-25,Christmas');await dialog().getByText('New holidays: 1',{exact:true}).waitFor();
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/holiday-import-mobile.png',fullPage:true});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'Mobile import should not overflow');
assert.deepEqual(errors,[]);
console.log('PASS: scoped user picker, email rotation/absence, country CSV preview/import/validation/dedup, workspace interval, policy flows, browser/account display timezone, mobile import');
} catch(error) {
console.error('Browser errors:',errors);console.error('Visible alert:',await page.locator('[role=alert]').textContent());await page.screenshot({path:'test-results/calendars-failure.png',fullPage:true});throw error;
} finally {await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+92
View File
@@ -0,0 +1,92 @@
const {openSetup}=require('./support/browser.cjs');
// Regression for dependency-aware removal and routing precedence through the real UI/API.
const {chromium}=require('playwright');
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(['localhost','127.0.0.1'].includes(new URL(base).hostname));
fs.mkdirSync('test-results',{recursive:true});
(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 state=()=>page.evaluate(()=>JSON.parse(JSON.stringify(Alpine.$data(document.body).workspace)));
const route=name=>page.locator('.routing-card').filter({has:page.getByText(name,{exact:true})});
const schedule=name=>page.locator('[data-resource-kind="schedules"]').filter({has:page.getByText(name,{exact:true})});
const order=async names=>{
await page.waitForFunction(names=>{
const app=Alpine.$data(document.body);
return !app.busy && JSON.stringify(app.orderedRoutes.map(r=>r.name))===JSON.stringify(names);
},names);
assert.deepEqual((await page.locator('.routing-card .resource-name > span:last-child').allTextContents()).map(s=>s.trim()),names);
assert.deepEqual((await state()).document.routes.map(r=>r.order),names.map((_,i)=>i));
};
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('configuration-'+unique);
await page.getByLabel('Display name',{exact:true}).fill('Configuration 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('Configuration '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
// Seed only this test's new team; all mutations under test use visible controls.
await page.evaluate(async()=>{
const app=Alpine.$data(document.body),doc=JSON.parse(JSON.stringify(app.doc));
doc.schedules=['Payments · primary','Payments · secondary','Alternate'].map(name=>({id:crypto.randomUUID(),name,handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}));
const policy={id:crypto.randomUUID(),name:'Payments escalation',steps:doc.schedules.slice(0,2).map((s,i)=>({delay_minutes:i*10,condition:'if-not-acked',recipient:{type:'schedule',id:s.id}})),repeat:{count:0,wait_minutes:0,reset_recipient_states:false,close_alert_after_all:false}};
doc.escalations=[policy];
doc.routes=['First','Second','Third'].map((name,i)=>({id:crypto.randomUUID(),name,order:[10,20,99][i],conditions:[{field:'tags',operation:'contains',value:name.toLowerCase(),negate:false}],window:null,escalation_id:policy.id}));
await app.api(app.endpoint('/config'),'PUT',{revision:app.workspace.revision,document:doc});await app.loadWorkspace();
});
await openSetup(page,'Schedules');
await schedule('Payments · secondary').locator('summary').click();
await schedule('Payments · secondary').getByRole('button',{name:'Remove',exact:true}).click();
await dialog().getByText('Payments escalation',{exact:true}).waitFor();
await dialog().getByText('Step 2 · 10 min',{exact:true}).waitFor();
await page.screenshot({path:'test-results/schedule-removal.png',fullPage:true});
await dialog().getByRole('button',{name:'Remove schedule & affected steps',exact:true}).click();
await dialog().waitFor({state:'hidden'});
let current=await state();assert.equal(current.document.schedules.length,2);assert.equal(current.document.escalations[0].steps.length,1);
assert.equal(current.document.escalations[0].steps[0].recipient.id,current.document.schedules[0].id);
await schedule('Payments · primary').locator('summary').click();
await schedule('Payments · primary').getByRole('button',{name:'Remove',exact:true}).click();
await dialog().getByText(/These policies would have no steps left/).waitFor();
assert(await dialog().getByRole('button',{name:'Remove schedule & affected steps',exact:true}).isDisabled());
await dialog().getByLabel('Existing references',{exact:true}).selectOption({label:'Use Alternate instead'});
await dialog().getByRole('button',{name:'Remove & redirect references',exact:true}).click();await dialog().waitFor({state:'hidden'});
current=await state();assert.equal(current.document.schedules.length,1);
assert.equal(current.document.escalations[0].steps[0].recipient.id,current.document.schedules[0].id);
await openSetup(page,'Escalations & routing');
while(await page.locator('.routing-card:not([open]) > summary').count())await page.locator('.routing-card:not([open]) > summary').first().click();
await page.getByRole('button',{name:'Drag Third to reorder',exact:true}).dragTo(route('First'),{targetPosition:{x:180,y:15}});
await order(['Third','First','Second']);
await page.reload();await openSetup(page,'Escalations & routing');
while(await page.locator('.routing-card:not([open]) > summary').count())await page.locator('.routing-card:not([open]) > summary').first().click();
await order(['Third','First','Second']);
await page.getByRole('button',{name:'Move Third down',exact:true}).click();await order(['First','Third','Second']);
await route('Second').getByRole('button',{name:'Edit',exact:true}).click();
assert.equal(await dialog().getByLabel('Position',{exact:true}).inputValue(),'3');
await dialog().getByLabel('Position',{exact:true}).selectOption('1');await save();await order(['Second','First','Third']);
await page.getByRole('button',{name:'+ Routing rule',exact:true}).click();
await dialog().getByLabel('Rule name',{exact:true}).fill('Fourth');
await dialog().getByLabel('Position',{exact:true}).selectOption('2');await save();await order(['Second','Fourth','First','Third']);
await route('Fourth').locator('summary').click();
await route('Fourth').getByRole('button',{name:'Remove',exact:true}).click();await order(['Second','First','Third']);
await page.screenshot({path:'test-results/routing-order.png',fullPage:true});
await page.setViewportSize({width:390,height:844});
await page.getByRole('button',{name:'Move Third up',exact:true}).click();await order(['Second','Third','First']);
assert(await page.getByRole('button',{name:'Move Second up',exact:true}).isDisabled());
assert(await page.getByRole('button',{name:'Move First down',exact:true}).isDisabled());
await page.screenshot({path:'test-results/routing-order-mobile.png',fullPage:true});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'Routing rules must fit mobile');
assert.deepEqual(errors,[]);
console.log('PASS: schedule dependency preview, step removal, last-step protection, redirect, native drag-and-drop, persistent routing order, editor position, insert/remove renumbering, mobile arrow controls');
} catch(error) {
console.error('Browser errors:',errors);console.error('Visible alert:',await page.locator('[role=alert]').textContent());await page.screenshot({path:'test-results/configuration-failure.png',fullPage:true});throw error;
} finally {await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+95
View File
@@ -0,0 +1,95 @@
const {chromium}=require('playwright');
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 chromium.launch({channel:process.env.MEGURI_BROWSER_CHANNEL||'chrome',headless:true});
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;});
+10
View File
@@ -0,0 +1,10 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{
const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace);
await p.evaluate(()=>{const a=Alpine.$data(document.body),alice=a.user.id,bob=a.people.find(x=>x.display_name==='Bob Chen').id,schedule=a.doc.schedules[0].id; a.doc.requests=[{id:'test',status:'pending',required_approvers:[alice,bob],approvals:[alice],legs:[{schedule_id:schedule,from_user:alice,to_user:bob,start:'2026-10-08T11:00:00+03:00',end:'2026-10-10T08:00:00.000Z'}]}];a.detail.coverage={total:1,issues:[{schedule_id:schedule,user_id:alice,start:'2026-10-08T08:00:00Z',end:'2026-10-11T08:00:00Z'}]};});
const box=p.locator('.coverage-problems');await box.waitFor({state:'visible'});assert.equal(await box.locator('.coverage-issue').count(),2);await box.getByText('Waiting for: Bob Chen',{exact:true}).waitFor();assert.equal(await box.getByText('No substitute assigned',{exact:true}).count(),1);assert.equal(await box.getByText('Awaiting approval',{exact:true}).count(),1);
const parts=await p.evaluate(()=>Alpine.$data(document.body).coverageItems);assert.equal(parts[0].end,parts[1].start);assert.equal(parts[1].start,'2026-10-10T08:00:00.000Z');assert(!parts[1].awaiting);
const strong=await box.locator('strong').first().boundingBox(),time=await box.locator('.coverage-times').first().boundingBox();assert(time.y>=strong.y+strong.height+6);await box.screenshot({path:'test-results/coverage-status.png'});
await p.setViewportSize({width:390,height:844});await box.screenshot({path:'test-results/coverage-status-mobile.png'});assert((await box.boundingBox()).width<=390);await box.getByRole('button',{name:'View requests'}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).view),'requests');console.log('PASS pending versus missing coverage, partial intervals, outstanding approvers, request navigation and responsive spacing');
}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+2
View File
@@ -0,0 +1,2 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');
(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+13
View File
@@ -0,0 +1,13 @@
const {chromium}=require('playwright');const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');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.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({paLine truncated
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=false;});
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=true;a.generationDraft.variables.find(v=>v.name==='projects').node.entries[0].entries.find(n=>n.key==='responsible').entries[0].value='not-an-email';});
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);assert(await d.locator('.map-status-error').count());await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();assert.equal(await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).workspace.revision}),revision);
await page.getByLabel('Workspace',{exact:true}).selectOption({label:'Regional Platform'});await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Service · Iteration · 4 passes'});const rows=await page.evaluate(()=>Alpine.$data(document.body).mapRows());const filtered=rows.findIndex(r=>r.status==='filtered');assert(filtered>=0);await d.getByLabel('Iteration pass',{exact:true}).selectOption(String(filtered));await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===0);await page.screenshot({path:'test-results/generation-map-filtered.png'});await d.getByRole('button',{name:'Close dialog',exact:true}).click();
assert.deepEqual(errors,[]);console.log('PASS generation map, actual iteration outputs, pass filtering, group expansion, zoom, source editor, mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1});
+2
View File
@@ -0,0 +1,2 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');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.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+84
View File
@@ -0,0 +1,84 @@
const {openSetup}=require('./support/browser.cjs');
const {chromium}=require('playwright');
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 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(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.
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.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('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();
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)});
+25
View File
@@ -0,0 +1,25 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict'),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));
(async()=>{const browser=await chromium.launch({channel:process.env.MEGURI_BROWSER_CHANNEL||'chrome',headless:true});const errors=[];let page;try{
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/?view=policies');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);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
const matrix=await page.evaluate(()=>Alpine.$data(document.body).selected);const support=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.id!==Alpine.$data(document.body).selected).id);
assert.equal(new URL(page.url()).searchParams.get('view'),'configuration');await page.reload();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
await page.getByRole('link',{name:'Requests',exact:true}).click();await page.waitForURL(/view=requests/);await page.getByLabel('Workspace',{exact:true}).selectOption(support);await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,support);assert.equal(new URL(page.url()).searchParams.get('workspace'),support);
await page.goBack();await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,matrix);assert.equal(await page.evaluate(()=>Alpine.$data(document.body).view),'requests');await page.goBack();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();await page.goForward();await page.getByRole('heading',{name:'Requests',exact:true}).waitFor();
const href=await page.getByRole('link',{name:'Activity',exact:true}).getAttribute('href');const tab=await context.newPage();await tab.goto(base+href);await tab.getByRole('heading',{name:'Activity',exact:true}).waitFor();assert.equal(await tab.evaluate(()=>Alpine.$data(document.body).selected),matrix);await tab.close();
// Locale is a saved display preference; use the real account form, then a fresh browser locale.
await page.locator('.account-button').click();await page.getByLabel('Language',{exact:true}).selectOption('ru');await page.getByRole('dialog').getByRole('button',{name:'Save changes',exact:true}).click();await page.waitForFunction(()=>document.documentElement.lang==='ru');await page.reload();await page.waitForFunction(()=>document.documentElement.lang==='ru' && Alpine.$data(document.body).workspace);await page.screenshot({path:'test-results/localization-ru.png',fullPage:true});
const snapshot=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return {revision:a.workspace.revision,schedules:JSON.stringify(a.doc.schedules),user:a.user};});
const fr=await browser.newContext({locale:'fr-CA',storageState:await context.storageState(),viewport:{width:390,height:844}});const fp=await fr.newPage();fp.on('pageerror',e=>errors.push(e.message));await fp.goto(base+'/?view=configuration&workspace='+matrix);await fp.waitForFunction(()=>document.documentElement.lang==='ru' && Alpine.$data(document.body).workspace);
await fp.evaluate(async()=>{const a=Alpine.$data(document.body);await a.api('/api/account','PUT',{...a.user,language:null});});await fp.reload();await fp.waitForFunction(()=>document.documentElement.lang==='fr' && Alpine.$data(document.body).workspace);await fp.getByRole('heading',{name:'Configuration des plannings',exact:true}).waitFor();assert.equal(await fp.evaluate(()=>Alpine.$data(document.body).workspace.revision),snapshot.revision);assert.equal(await fp.evaluate(()=>JSON.stringify(Alpine.$data(document.body).doc.schedules)),snapshot.schedules);assert(await fp.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await fp.screenshot({path:'test-results/localization-fr-mobile.png',fullPage:true});
assert.equal(await fp.evaluate(()=>I18n.diagnostic('Choose an existing workspace variable')),'Choisissez une variable existante de l’espace');assert.equal(await fp.evaluate(()=>I18n.translate('coverage_to_arrange',{count:'<b>{person}</b>'},'fr')),'Intervalles sans remplacement : <b>{person}</b>');
// Invalid preference cannot be persisted.
const status=await fp.evaluate(async()=>{const a=Alpine.$data(document.body);return (await fetch('/api/account',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':a.csrf},body:JSON.stringify({...a.user,language:'xx'})})).status;});assert.equal(status,422);
await fr.close();
for(const [locale,expected]of [['ru-RU','ru'],['de-DE','en'],['fr-CA','fr']]){const cx=await browser.newContext({locale});const p=await cx.newPage();await p.goto(base);await p.waitForFunction(()=>Alpine.$data(document.body).ready);assert.equal(await p.locator('html').getAttribute('lang'),expected);await cx.close();}
// Parse templates rather than rendered text: built-in copy must be catalog-backed.
for(const file of fs.readdirSync('static').filter(f=>f.endsWith('.html'))){const source=fs.readFileSync('static/'+file,'utf8');const literals=await page.evaluate(source=>{const doc=new DOMParser().parseFromString(source,'text/html'),out=[];const visit=node=>{for(const n of node.childNodes){if(n.nodeType===3 && n.textContent.trim())out.push(n.textContent.trim());if(n.nodeType===1){if(n.tagName==='TEMPLATE')visit(n.content);else if(!['SCRIPT','STYLE'].includes(n.tagName))visit(n);}}};visit(doc);return out;},source);assert.deepEqual(literals,[],file+' contains inline text');}
const catalog=JSON.parse(fs.readFileSync('static/locales/messages.json','utf8'));const placeholders=s=>[...s.matchAll(/\{(\w+)\}/g)].map(m=>m[1]).sort();for(const [key,entry]of Object.entries(catalog))for(const lang of ['en','ru','fr']){assert(entry[lang],key+': '+lang);assert.deepEqual(placeholders(entry[lang]),placeholders(entry.en),key);}
assert.deepEqual(errors,[]);console.log('PASS: menu links, reload, cross-team Back/Forward, new tab, language persistence and browser fallback, translated validation, unchanged schedules, mobile, complete catalog, no inline template copy');
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+1
View File
@@ -0,0 +1 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+1
View File
@@ -0,0 +1 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+14
View File
@@ -0,0 +1,14 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto('http://127.0.0.1:3000/?view=configuration');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.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();
const panel=page.locator('.rule-result');const ready=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});await ready();
assert.equal(await panel.locator('.rule-result-row').count(),18);const text=await panel.innerText();assert.match(text,/Payments/);assert.match(text,/Alice Morgan/);assert.match(text,/Time off/);assert.match(text,/Escalation/);assert.match(text,/Routing/);await panel.screenshot({path:'test-results/rule-result.png'});
// A rule with escalation outputs only remains visible and resolves a manual target.
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.form.blocks=a.form.blocks.filter(b=>b.kind==='escalation');a.form.blocks[0].steps[0].recipient=a.literal(a.doc.schedules.find(s=>s.name==='Time off').id);});await ready();assert.equal(await panel.locator('.rule-result-row').count(),6);
await page.evaluate(()=>{Alpine.$data(document.body).form.enabled=false;});await ready();assert.match(await panel.innerText(),/Rule disabled/);assert.equal(await panel.locator('.rule-result-row').count(),0);
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.form.enabled=true;a.form.blocks[0].steps[0].delay=a.literal('broken');});await ready();assert.match(await panel.innerText(),/Check the configuration errors/);assert.equal(await panel.locator('.rule-result-row').count(),0);
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.form.blocks[0].steps[0].delay=a.literal(0);a.form.when={left:a.literal('a'),operation:'equals',right:a.literal('b')};});await ready();assert.match(await panel.innerText(),/No resources/);
await page.setViewportSize({width:390,height:844});await panel.screenshot({path:'test-results/rule-result-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);assert.deepEqual(errors,[]);console.log('PASS server-generated rule results: all types, resolved names, escalation-only, disabled, invalid, filtered and mobile');
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+2
View File
@@ -0,0 +1,2 @@
const {chromium}=require('playwright'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');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.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
+3
View File
@@ -0,0 +1,3 @@
const {chromium}=require('playwright');const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');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.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
+89
View File
@@ -0,0 +1,89 @@
const {openSetup}=require('./support/browser.cjs');
// Run against an explicitly seeded localhost development instance.
const { chromium } = require('playwright');
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), 'Browser tests require a disposable local instance');
fs.mkdirSync('test-results', { recursive: true });
(async () => {
const browser = await chromium.launch({ ...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL || 'chrome'}), headless: true });
const errors = [];
async function signIn(name) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.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.getByRole('heading', { name: 'Calendar', exact: true }).waitFor();
return page;
}
async function save(page) {
await page.getByRole('dialog').getByRole('button', { name: 'Save changes', exact: true }).click();
await page.getByRole('dialog').waitFor({ state: 'hidden' });
}
try {
const alice = await signIn('alice');
await alice.locator('.day').first().waitFor();
await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true });
const label = 'Browser ' + Date.now();
await alice.getByRole('button', { name: 'New workspace' }).click();
await alice.getByRole('dialog').getByLabel('Name', { exact: true }).fill(label); await save(alice);
const created = await alice.evaluate(() => { const a=Alpine.$data(document.body); return {record:a.workspace, listing:a.directoryState}; });
assert.deepEqual(Object.keys(created.listing), ['workspaces']);
assert.equal(created.listing.workspaces.filter(w=>w.name===label).length,1);
assert.equal(created.record.document.name,label);
assert.equal(created.record.revision,1);
assert.equal('tenant_id' in created.record,false);
assert.equal((await alice.request.get(base+'/api/tenants')).status(),404);
await openSetup(alice,'Workspace settings');
await alice.getByRole('button', { name: '+ Add teammate' }).click();
await alice.getByRole('dialog').getByLabel('Registered username').fill('bob'); await save(alice);
await openSetup(alice,'Schedules');
await alice.locator('.schedule-create > summary').click();
await alice.getByRole('button', { name: '+ Rotation', exact: true }).click();
const start = new Date(Date.now() + 2 * 86400000).toISOString().slice(0,10);
const end = new Date(Date.now() + 3 * 86400000).toISOString().slice(0,10);
await alice.getByRole('dialog').getByLabel('Schedule name').fill('Weekly rotation');
await alice.getByRole('dialog').getByLabel('Anchor date').fill(start.split('-').reverse().join('-')); await save(alice);
await alice.getByRole('link', { name: 'Calendar', exact: true }).click();
await alice.locator('[data-duty-date="'+start+'"]').click();
await alice.getByRole('dialog').getByRole('button', { name: 'Bob Chen', exact: true }).click();
await alice.getByRole('dialog').getByLabel('Comment (optional)', {exact:true}).fill('Browser regression: planned appointment');
await alice.getByRole('dialog').getByRole('button', {name:'Send request',exact:true}).click();
await alice.getByRole('dialog').waitFor({state:'hidden'});
const bob = await signIn('bob');
const selectedTeam = await bob.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name===label).id,label);
await bob.getByLabel('Workspace', { exact: true }).selectOption(selectedTeam);
await bob.getByRole('link', { name: /Requests/ }).click();
await bob.getByRole('button', { name: 'Accept', exact: true }).click();
await bob.locator('.badge.applied').waitFor();
await bob.screenshot({ path: 'test-results/approved-request.png', fullPage: true });
await bob.getByRole('link', { name: 'Calendar', exact: true }).click();
await bob.waitForFunction(start => [...document.querySelectorAll('.day')].some(el => el.dataset.dutyDate===start && el.textContent.includes('Bob')), start);
const session = await alice.evaluate(() => fetch('/api/session').then(r=>r.json()));
const workspace = await alice.evaluate(() => fetch('/api/workspaces').then(r=>r.json()));
const team = workspace.workspaces.find(t => t.name === label);
const endpoint = `/api/workspaces/${team.id}`;
let response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: base }, data: {} });
assert.equal(response.status(), 403, 'Missing CSRF must be rejected');
response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: 'https://other.example', 'X-CSRF-Token': session.csrf }, data: {} });
assert.equal(response.status(), 403, 'Cross-origin mutation must be rejected');
const carol = await signIn('carol');
response = await carol.request.get(base + endpoint); assert.equal(response.status(),404,'Other team members cannot read this team');
const stranger = await browser.newContext();response = await stranger.request.get(base + endpoint);assert.equal(response.status(),401);
await bob.setViewportSize({ width:390,height:844 });
await bob.getByLabel('Choose workspace', {exact:true}).waitFor();
await bob.screenshot({ path:'test-results/calendar-mobile.png',fullPage:true });
assert(await bob.evaluate(() => document.documentElement.scrollWidth<=innerWidth), 'Mobile layout must not overflow');
assert.deepEqual(errors, [], 'No browser runtime errors');
console.log('PASS: calendar, workspace creation, membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout');
} catch (error) {
console.error('Browser errors:', errors);
for(const context of browser.contexts()) for(const page of context.pages()) {
console.error('Visible alert:', await page.locator('[role=alert]').textContent().catch(()=>''));
await page.screenshot({ path:'test-results/failure.png',fullPage:true }).catch(()=>{});
}
throw error;
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode=1; });
+690
View File
@@ -0,0 +1,690 @@
use chrono::{Duration, NaiveDate, Utc};
use meguri::{
demo,
domain::*,
engine,
generation::{self, Binding, Rule, Variable},
};
use serde_json::json;
use std::collections::BTreeSet;
use uuid::Uuid;
struct Example {
doc: WorkspaceDocument,
team: Uuid,
people: [Uuid; 2],
variables: Vec<Variable>,
rules: Vec<Rule>,
day: NaiveDate,
}
impl Example {
fn new() -> Self {
let people = [Uuid::new_v4(), Uuid::new_v4()];
let day = "2026-10-19".parse().unwrap();
let (variables, rules) = demo::project_matrix(day, people[0], people[1]);
let mut doc = WorkspaceDocument::new("Projects".into());
doc.timezone = "Asia/Nicosia".into();
Self {
doc,
team: Uuid::new_v4(),
people,
variables,
rules,
day,
}
}
fn expand(&self) -> generation::Expansion {
generation::expand(
&self.doc,
self.team,
&self.variables,
&self.rules,
0,
&WorkingWindow {
timezone: self.doc.timezone.clone(),
..WorkingWindow::default()
},
&self.people.into_iter().collect(),
engine::handover(&self.doc, self.day).unwrap(),
)
}
}
#[test]
fn matrix_keeps_project_priority_and_weekly_monday_handover_across_dst() {
let e = Example::new();
let out = e.expand();
assert!(out.issues.is_empty(), "{:?}", out.issues);
assert_eq!(out.document.schedules.len(), 4);
assert_eq!(out.document.routes.len(), 4);
assert!(
out.document.routes[..3]
.iter()
.all(|r| r.window.as_ref().unwrap().timezone == "Asia/Nicosia")
);
assert!(out.document.routes[3].conditions.is_empty());
assert!(out.document.routes[3].window.is_none());
let duty = out
.document
.schedules
.iter()
.find(|s| s.name == "Time off")
.unwrap();
let project = out
.document
.schedules
.iter()
.find(|s| s.name == "Payments · owners")
.unwrap();
let from = engine::handover(&out.document, e.day).unwrap();
let to = engine::handover(&out.document, e.day + Duration::days(14)).unwrap();
let timeline = engine::timeline(&out.document, from, to).unwrap();
let shifts: Vec<_> = timeline
.assignments
.iter()
.filter(|a| a.schedule_id == duty.id)
.collect();
assert_eq!(shifts.len(), 2);
assert_eq!(shifts[0].user_id, e.people[0]);
assert_eq!(shifts[1].user_id, e.people[1]);
assert_eq!((shifts[0].end - shifts[0].start).num_hours(), 169);
assert_eq!(shifts[0].end, shifts[1].start);
assert!(
timeline
.assignments
.iter()
.filter(|a| a.schedule_id == project.id)
.all(|a| a.user_id == e.people[0])
);
}
#[test]
fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
let e = Example::new();
let mut doc = e.expand().document;
let project = doc
.schedules
.iter()
.find(|s| s.name == "Payments · owners")
.unwrap()
.id;
let duty = doc
.schedules
.iter()
.find(|s| s.name == "Time off")
.unwrap()
.id;
let third = doc.people[0].id;
doc.people.extend(e.people.map(|id| Person {
id,
user_id: Some(id),
email: None,
display_name: "Person".into(),
country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()),
}));
doc.holidays.push(Holiday {
country: "CY".into(),
date: e.day,
name: "Holiday".into(),
});
let from = engine::handover(&doc, e.day).unwrap();
let to = engine::handover(&doc, e.day + Duration::days(1)).unwrap();
let owner = |doc: &WorkspaceDocument| {
engine::timeline(doc, from, to)
.unwrap()
.assignments
.into_iter()
.find(|a| a.schedule_id == project)
.unwrap()
.user_id
};
assert_eq!(owner(&doc), e.people[1]);
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: e.people[1],
start: e.day,
end: e.day,
});
// The third email contact is CY too, so CY's holiday skips it independently.
assert_eq!(owner(&doc), e.people[0]);
doc.holidays.clear();
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: e.people[0],
start: e.day,
end: e.day,
});
assert_eq!(owner(&doc), third);
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: third,
start: e.day,
end: e.day,
});
let substitute = Uuid::new_v4();
doc.people.push(Person {
id: substitute,
user_id: None,
email: Some("cover@example.com".into()),
display_name: "Cover".into(),
country: Some("CY".into()),
});
doc.requests.push(ReplacementRequest {
effective_legs: None,
id: Uuid::new_v4(),
absence_id: None,
requested_by: e.people[0],
reason: "Cover".into(),
applied_at: Some(from),
forced_by: Some(e.people[0]),
created_at: from - Duration::days(1),
legs: vec![ReplacementLeg {
schedule_id: duty,
start: from,
end: to,
from_user: e.people[0],
to_user: substitute,
}],
status: RequestStatus::Applied,
required_approvers: BTreeSet::new(),
approvals: BTreeSet::new(),
force_reason: Some("Cover".into()),
});
assert_eq!(owner(&doc), substitute);
}
#[test]
fn renames_and_reorders_preserve_ids_and_approved_replacements_block_removal() {
let mut e = Example::new();
let first = e.expand();
assert!(first.issues.is_empty());
e.doc = first.document;
let old_ids: BTreeSet<_> = e.doc.schedules.iter().map(|s| s.id).collect();
e.variables[0].name = "Products".into();
e.variables[0].node.entries.reverse();
let entry = e.variables[0]
.node
.entries
.iter_mut()
.find(|n| n.value == "Payments")
.unwrap();
entry.value = json!("Payments v2");
e.variables[1]
.node
.entries
.iter_mut()
.find(|n| n.key == "Payments")
.unwrap()
.key = "Payments v2".into();
let again = e.expand();
assert!(again.issues.is_empty(), "{:?}", again.issues);
assert!(
again
.changes
.iter()
.filter(|c| c.kind == "route" && c.action == "update")
.count()
>= 2,
"Changed routing precedence must be visible in the preview"
);
assert_eq!(
old_ids,
again.document.schedules.iter().map(|s| s.id).collect()
);
let id = e
.doc
.schedules
.iter()
.find(|s| s.name == "Payments · owners")
.unwrap()
.id;
let from = engine::handover(&e.doc, e.day + Duration::days(1)).unwrap();
e.doc.requests.push(ReplacementRequest {
effective_legs: None,
id: Uuid::new_v4(),
absence_id: None,
requested_by: e.people[0],
reason: "Cover".into(),
applied_at: Some(from),
forced_by: Some(e.people[0]),
created_at: from - Duration::days(1),
legs: vec![ReplacementLeg {
schedule_id: id,
start: from,
end: from + Duration::hours(1),
from_user: e.people[0],
to_user: e.people[1],
}],
status: RequestStatus::Applied,
required_approvers: BTreeSet::new(),
approvals: BTreeSet::new(),
force_reason: Some("Cover".into()),
});
e.variables[0]
.node
.entries
.retain(|n| n.value != "Payments v2");
assert!(
e.expand()
.issues
.iter()
.any(|i| i.message.contains("approved replacements"))
);
e.doc.requests[0].status = RequestStatus::Pending;
let out = e.expand();
assert!(out.issues.is_empty(), "{:?}", out.issues);
assert_eq!(out.document.requests[0].status, RequestStatus::Cancelled);
let mut manual = e.doc.clone();
manual.schedules[0].name = "Edited directly".into();
assert!(generation::validate_manual_edit(&e.doc, &mut manual).is_err());
}
#[test]
fn bad_dictionary_values_types_scope_cycles_and_bounds_have_actionable_errors() {
let mut e = Example::new();
e.variables[1].node.entries.retain(|n| n.key != "Search");
let out = e.expand();
assert!(
out.issues
.iter()
.any(|i| i.path.contains("Search") && i.message.contains("key ‘Search’"))
);
let mut e = Example::new();
e.variables[1]
.node
.entries
.iter_mut()
.find(|n| n.key == "Payments")
.unwrap()
.entries
.clear();
assert!(
e.expand()
.issues
.iter()
.any(|i| i.message.contains("1–100"))
);
let mut e = Example::new();
e.rules[1].blocks[0].fields.insert(
"participants".into(),
Binding {
kind: "literal".into(),
value: json!("wrong type"),
..Default::default()
},
);
assert!(e.expand().issues.iter().any(|i| i.message.contains("list")));
let mut e = Example::new();
let scoped = e.rules[1].blocks[0].id;
e.rules[0].blocks[1].steps[0].recipient.output = Some(scoped);
assert!(
e.expand()
.issues
.iter()
.any(|i| i.message.contains("output block"))
);
let mut e = Example::new();
let own = e.rules[1].blocks[0].id;
e.rules[1].blocks[0]
.fields
.get_mut("fallback")
.unwrap()
.output = Some(own);
assert!(
e.expand()
.issues
.iter()
.any(|i| i.message.contains("cycle"))
);
let mut e = Example::new();
for _ in 0..5 {
let mut i = e.rules[1].iterations[0].clone();
i.id = Uuid::new_v4();
e.rules[1].iterations.push(i);
}
assert!(
e.expand()
.issues
.iter()
.any(|i| i.message.contains("at most 4"))
);
let mut e = Example::new();
let duplicate = e.variables[1].node.entries[0].clone();
e.variables[1].node.entries.push(duplicate);
assert!(!generation::validate_variables(&e.variables).is_empty());
}
#[test]
fn thirty_projects_expand_deterministically_and_tenants_do_not_share_ids() {
let mut e = Example::new();
let project = e.variables[0].node.entries[0].clone();
let owners = e.variables[1].node.entries[0].clone();
e.variables[0].node.entries.clear();
e.variables[1].node.entries.clear();
for i in 0..30 {
let name = format!("Project {i}");
let mut p = project.clone();
p.id = Uuid::new_v4();
p.value = json!(name);
e.variables[0].node.entries.push(p);
let mut o = owners.clone();
o.id = Uuid::new_v4();
o.key = name;
for n in &mut o.entries {
n.id = Uuid::new_v4();
}
e.variables[1].node.entries.push(o);
}
let first = e.expand();
assert!(first.issues.is_empty(), "{:?}", first.issues);
assert_eq!(first.document.schedules.len(), 31);
assert_eq!(
serde_json::to_value(&first.document).unwrap(),
serde_json::to_value(e.expand().document).unwrap()
);
e.team = Uuid::new_v4();
let other = e.expand();
assert!(
other.document.schedules.iter().all(|s| !first
.document
.schedules
.iter()
.any(|old| old.id == s.id))
);
let (variables, rules) = demo::support_queues(e.day, e.people[0], e.people[1]);
let support = generation::expand(
&e.doc,
e.team,
&variables,
&rules,
0,
&WorkingWindow::default(),
&e.people.into_iter().collect(),
Utc::now(),
);
assert!(support.issues.is_empty(), "{:?}", support.issues);
assert_eq!(support.document.escalations[0].steps[1].delay_minutes, 15);
}
#[test]
fn legacy_two_owner_rules_keep_dynamic_bindings_and_resource_identity() {
let mut example = Example::new();
for variable in &mut example.variables {
if variable.name == "responsible" {
for entry in &mut variable.node.entries {
entry.entries.truncate(2);
}
}
}
let expected = example.expand();
let mut rules = serde_json::to_value(&example.rules).unwrap();
for rule in rules.as_array_mut().unwrap() {
for block in rule["blocks"].as_array_mut().unwrap() {
if block["kind"] == "priority" {
block["kind"] = json!("coverage");
let fields = block["fields"].as_object_mut().unwrap();
let mut first = fields.remove("participants").unwrap();
first["path"] = json!(["1"]);
let mut second = first.clone();
second["path"] = json!(["2"]);
fields.insert("primary".into(), first);
fields.insert("secondary".into(), second);
}
}
}
example.rules = serde_json::from_value(rules).unwrap();
let actual = example.expand();
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
assert_eq!(
serde_json::to_value(&expected.document.schedules).unwrap(),
serde_json::to_value(&actual.document.schedules).unwrap()
);
assert_eq!(
serde_json::to_value(&expected.document.routes).unwrap(),
serde_json::to_value(&actual.document.routes).unwrap()
);
assert!(
example
.rules
.iter()
.flat_map(|r| &r.blocks)
.all(|b| b.kind != "coverage")
);
}
#[test]
fn legacy_schedule_deserializes_to_priority_without_changing_references() {
let id = Uuid::new_v4();
let primary = Uuid::new_v4();
let secondary = Uuid::new_v4();
let fallback = Uuid::new_v4();
let schedule: Schedule = serde_json::from_value(json!({"id":id,"name":"Legacy","handover":"09:30:00","type":"coverage","primary":primary,"secondary":secondary,"fallback":fallback})).unwrap();
let value = serde_json::to_value(&schedule).unwrap();
assert_eq!(value["type"], "priority");
assert_eq!(value["participants"], json!([primary, secondary]));
assert_eq!(value["fallback"], json!(fallback));
assert_eq!(value["id"], json!(id));
assert_eq!(value["handover"], "09:30:00");
assert!(value.get("primary").is_none());
}
#[test]
fn legacy_records_normalize_recursively_and_preserve_generated_resources() {
let mut example = Example::new();
let expected = example.expand();
let original = serde_json::to_value(&example.variables).unwrap();
let legacy = serde_json::to_string(&example.variables)
.unwrap()
.replace("\"kind\":\"dictionary\"", "\"kind\":\"record\"");
example.variables = serde_json::from_str(&legacy).unwrap();
assert_eq!(serde_json::to_value(&example.variables).unwrap(), original);
let actual = example.expand();
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
assert_eq!(
serde_json::to_value(&actual.document.schedules).unwrap(),
serde_json::to_value(&expected.document.schedules).unwrap()
);
let id = Uuid::new_v4();
let nested: generation::Node = serde_json::from_value(json!({"id":id,"kind":"record","entries":[{"id":Uuid::new_v4(),"kind":"record","key":"settings","entries":[{"id":Uuid::new_v4(),"kind":"text","key":"lead","value":"alice@example.com"}]}]})).unwrap();
assert_eq!(nested.id, id);
assert_eq!(nested.kind, "dictionary");
assert_eq!(nested.entries[0].kind, "dictionary");
assert_eq!(nested.entries[0].entries[0].value, "alice@example.com");
}
#[test]
fn advanced_demos_expand_nested_portfolios_and_tier_lookups() {
let mut e = Example::new();
(e.variables, e.rules) = demo::regional_platform(e.day, e.people[0], e.people[1]);
let expanded = e.expand();
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
assert_eq!(expanded.document.schedules.len(), 4);
assert_eq!(expanded.document.routes.len(), 3);
assert!(
!expanded
.document
.schedules
.iter()
.any(|s| s.name.contains("Sandbox"))
);
let trace = &expanded.trace[1];
assert_eq!(trace["stages"][0]["count"], 2);
let singleton = &expanded.trace[0]["rows"][0]["blocks"][0];
assert_eq!(singleton["fields"]["participants"], json!([e.people[0]]));
assert_eq!(singleton["fields"]["name"], "Incident commander");
assert!(singleton["fields"]["participants"].is_array());
assert_eq!(trace["stages"][1]["count"], 4);
let rows = trace["rows"].as_array().unwrap();
assert_eq!(rows.len(), 4);
assert_eq!(rows.iter().filter(|r| r["status"] == "filtered").count(), 1);
assert!(
rows.iter()
.filter(|r| r["status"] == "filtered")
.all(|r| r["resources"].as_array().unwrap().is_empty())
);
let produced: BTreeSet<Uuid> = rows
.iter()
.flat_map(|r| r["resources"].as_array().unwrap())
.map(|id| serde_json::from_value(id.clone()).unwrap())
.collect();
assert_eq!(
produced,
expanded
.document
.generation
.managed
.iter()
.filter(|m| m.rule == e.rules[1].id)
.map(|m| m.id)
.collect()
);
assert!(
rows.iter()
.any(|r| r["inputs"].to_string().contains("Pacific · Sandbox"))
);
let pacific = expanded
.document
.schedules
.iter()
.find(|s| s.name == "Pacific · Gateway")
.unwrap();
assert_eq!(pacific.handover.to_string(), "05:00:00");
assert!(
matches!(&pacific.kind, ScheduleKind::Rotation {shift_days:2, participants,..} if participants[0] == e.people[1])
);
assert!(
expanded
.document
.escalations
.iter()
.all(|p| p.steps.len() == 2)
);
(e.variables, e.rules) = demo::service_tiers(e.day, e.people[0], e.people[1]);
let expanded = e.expand();
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
assert_eq!(expanded.document.routes.len(), 4);
assert!(
!expanded
.document
.schedules
.iter()
.any(|s| s.name == "Legacy export")
);
let checkout = expanded
.document
.escalations
.iter()
.find(|p| p.name == "Checkout")
.unwrap();
assert_eq!(checkout.steps[1].delay_minutes, 5);
assert_eq!(checkout.repeat.count, 2);
assert_eq!(checkout.repeat.wait_minutes, 10);
assert!(
expanded
.document
.routes
.iter()
.find(|r| r.name == "Checkout")
.unwrap()
.window
.is_none()
);
assert!(
expanded
.document
.routes
.iter()
.find(|r| r.name == "Analytics")
.unwrap()
.window
.is_some()
);
// One tier edit changes every referencing product without replacing resource identity.
let tiers = e
.variables
.iter_mut()
.find(|v| v.name == "service_tiers")
.unwrap();
let standard = tiers
.node
.entries
.iter_mut()
.find(|n| n.key == "standard")
.unwrap();
standard
.entries
.iter_mut()
.find(|n| n.key == "delay")
.unwrap()
.value = json!(45);
let changed = e.expand();
assert!(changed.issues.is_empty(), "{:?}", changed.issues);
for name in ["Analytics", "Merchant portal"] {
let before = expanded
.document
.escalations
.iter()
.find(|p| p.name == name)
.unwrap();
let after = changed
.document
.escalations
.iter()
.find(|p| p.name == name)
.unwrap();
assert_eq!(before.id, after.id);
assert_eq!(after.steps[1].delay_minutes, 45);
}
// A disabled entry need not resolve its missing retired tier until enabled.
let products = e
.variables
.iter_mut()
.find(|v| v.name == "products")
.unwrap();
products
.node
.entries
.iter_mut()
.find(|n| n.key == "Legacy export")
.unwrap()
.entries
.iter_mut()
.find(|n| n.key == "enabled")
.unwrap()
.value = json!(true);
assert!(!e.expand().issues.is_empty());
}
#[test]
fn participant_error_has_source_ids_and_marks_cascading_configuration_error() {
let mut e = Example::new();
let variable = e
.variables
.iter_mut()
.find(|v| v.name == "on_call")
.unwrap();
variable.node.entries[0].kind = "date".into();
variable.node.entries[0].value = json!("2026-09-24");
let out = e.expand();
let source = out
.issues
.iter()
.find(|i| i.message.contains("not an email"))
.unwrap();
assert_eq!(source.rule_id, Some(e.rules[0].id));
assert_eq!(source.block_id, Some(e.rules[0].blocks[0].id));
assert!(out.issues.iter().any(|i| i.secondary));
}
#[test]
fn removed_fixed_assignee_is_rejected_in_documents_and_generation() {
assert!(
serde_json::from_value::<meguri::domain::ScheduleKind>(json!({
"type": "backup", "user": uuid::Uuid::new_v4()
}))
.is_err()
);
let mut example = Example::new();
example.rules[0].blocks[0].kind = "backup".into();
assert!(!example.expand().issues.is_empty());
}
+1998
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
exports.openSetup = async (page, name) => {
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
const target=page.getByRole('link',{name:combined?'Schedule setup':name,exact:true});
if(!await target.isVisible())await page.getByRole('button',{name:'Setup',exact:true}).click();
await target.click();
if(combined)await page.locator(name==='Schedules'?'#schedules':name==='Escalations & routing'?'#schedules':'#generation').scrollIntoViewIfNeeded();
};
+170
View File
@@ -0,0 +1,170 @@
//! Stateful HTTP fixture. It verifies wire methods and bodies, not live credentials.
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
#[derive(Default)]
pub struct State {
pub objects: BTreeMap<String, Value>,
pub writes: usize,
pub fail_once: Option<String>,
pub external_override: bool,
pub users: Vec<Value>,
pub lookups: usize,
}
pub struct Server {
pub base: String,
pub state: Arc<Mutex<State>>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Server {
fn drop(&mut self) {
self.task.abort();
}
}
impl Server {
pub async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let state = Arc::new(Mutex::new(State::default()));
let copy = state.clone();
let task = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let shared = copy.clone();
tokio::spawn(async move {
let mut buffer = vec![];
let (head_end, length) = loop {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buffer[..end]);
let length = head
.lines()
.find_map(|l| {
l.to_lowercase()
.strip_prefix("content-length: ")
.and_then(|v| v.parse::<usize>().ok())
})
.unwrap_or(0);
break (end + 4, length);
}
};
while buffer.len() < head_end + length {
let mut chunk = [0; 4096];
let n = socket.read(&mut chunk).await.unwrap();
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
}
let head = String::from_utf8_lossy(&buffer[..head_end]);
assert!(head.to_lowercase().contains("authorization: basic "));
let mut first = head.lines().next().unwrap().split_whitespace();
let method = first.next().unwrap();
let path = first.next().unwrap().split('?').next().unwrap();
let body = if length > 0 {
serde_json::from_slice(&buffer[head_end..head_end + length]).unwrap()
} else {
Value::Null
};
let (code, result) = handle(&mut shared.lock().unwrap(), method, path, body);
let body = result.to_string();
let response = format!(
"HTTP/1.1 {code} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
});
}
});
Self { base, state, task }
}
}
fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Value) {
if method == "POST" && s.fail_once.as_ref().is_some_and(|p| path.ends_with(p)) {
s.fail_once = None;
return (429, json!({"error":"Try later"}));
}
if method == "GET" {
if path.ends_with("/user/search") {
s.lookups += 1;
return (200, json!(s.users));
}
if path.ends_with("/overrides") {
return (
200,
json!({"values":if s.external_override{vec![json!({"alias":"external"})]}else{vec![]}}),
);
}
if let Some(parent) = path.strip_suffix("/rotations") {
return (
200,
json!({"values":s.objects.get(parent).unwrap()["rotations"]}),
);
}
if let Some(value) = s.objects.get(path) {
return (200, value.clone());
}
if path.ends_with("/schedules")
|| path.ends_with("/escalations")
|| path.ends_with("/routing-rules")
{
return (
200,
json!({"values":s.objects.iter().filter(|(k,_)|k.rsplit_once('/').is_some_and(|(p,_)|p==path)).map(|(_,v)|v.clone()).collect::<Vec<_>>()}),
);
}
return (404, json!({}));
}
if method == "DELETE" {
s.writes += 1;
if path.ends_with("/overrides/external") {
s.external_override = false;
} else {
s.objects.remove(path);
}
return (200, json!({"result":"deleted"}));
}
if path.contains("routing-rules") {
assert!(body["order"].as_u64().unwrap() <= 100);
assert!(body["notify"].is_object());
}
if path.contains("escalations") {
assert!((1..=20).contains(&body["repeat"]["count"].as_u64().unwrap()));
assert!(body["rules"].is_array());
}
if path.contains("schedules") {
for rotation in body["rotations"].as_array().unwrap() {
assert_eq!(rotation["participants"].as_array().unwrap().len(), 1);
assert!(
rotation["endDate"].as_str().unwrap() > rotation["startDate"].as_str().unwrap()
);
}
}
s.writes += 1;
if method == "POST" {
let id = uuid::Uuid::new_v4().to_string();
body["id"] = json!(id);
s.objects.insert(format!("{path}/{id}"), body.clone());
return (201, body);
}
assert_eq!(method, "PATCH");
if let Some(previous) = s.objects.get(path) {
body["id"] = previous["id"].clone();
s.objects.insert(path.into(), body.clone());
(200, body)
} else {
(404, json!({}))
}
}
+135
View File
@@ -0,0 +1,135 @@
use chrono::{Duration, Utc};
use cot::db::Model;
use meguri::{
domain::*,
models::UserRow,
service,
store::{Store, WorkspaceRecord},
};
use uuid::Uuid;
pub struct Fixture {
pub db: Store,
pub people: [User; 3],
pub team: WorkspaceRecord,
pub schedule: Uuid,
pub start: chrono::DateTime<Utc>,
}
impl Fixture {
pub async fn new() -> Self {
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
let db = Store::connect(&url).await.unwrap();
let mut people = vec![];
for name in ["Alice", "Bob", "Carol"] {
let user = User {
language: None,
id: Uuid::new_v4(),
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
country: "CY".into(),
timezone: None,
};
UserRow {
language: None,
demo_deleted_at: None,
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone(),
timezone: None,
password_hash: "test-only-unusable-password".into(),
created_at: Utc::now(),
}
.insert(&db.db)
.await
.unwrap();
people.push(user);
}
let people: [User; 3] = people.try_into().unwrap();
let mut team = service::create_workspace(&db, people[0].id, "Test team".into())
.await
.unwrap();
for person in &people[1..] {
team = service::set_member(
&db,
people[0].id,
team.id,
team.revision,
person.username.clone(),
Permission::member(),
)
.await
.unwrap();
}
let schedule = Uuid::new_v4();
let day = Utc::now().date_naive() + Duration::days(2);
let mut doc = team.document.clone();
doc.timezone = "UTC".into();
doc.schedules.push(Schedule {
handover: default_handover(),
id: schedule,
name: "General".into(),
kind: ScheduleKind::Rotation {
participants: people.iter().map(|p| p.id).collect(),
anchor: day,
shift_days: 7,
},
});
team = service::update_document(&db, people[0].id, team.id, team.revision, doc)
.await
.unwrap();
let start = meguri::engine::handover(&team.document, day).unwrap();
Self {
db,
people,
team,
schedule,
start,
}
}
pub fn leg(&self) -> ReplacementLeg {
ReplacementLeg {
schedule_id: self.schedule,
start: self.start,
end: self.start + Duration::days(1),
from_user: self.people[0].id,
to_user: self.people[1].id,
}
}
pub async fn propose(&mut self, legs: Vec<ReplacementLeg>, force: bool) {
self.team = service::request_replacement(
&self.db,
self.people[0].id,
self.team.id,
service::NewRequest {
remaining: false,
revision: self.team.revision,
reason: "Test replacement".into(),
legs,
force,
},
)
.await
.unwrap();
}
pub fn decision(&self, action: &str) -> service::RequestDecision {
service::RequestDecision {
revision: self.team.revision,
action: action.into(),
reason: "Test decision".into(),
}
}
pub async fn prioritize_publication(&self) {
// The worker takes a bounded batch. Repeated runs leave other test tenants
// in this disposable database, so explicitly put this test's job first.
self.db
.db
.raw_with(
"UPDATE meguri__sync_jobs SET next_attempt='1970-01-01T00:00:00Z' WHERE workspace_id=$1",
&[&self.team.id.to_string()],
)
.await
.unwrap();
}
}