Files
meguri/tests/browser-cover.cjs
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00

98 lines
9.6 KiB
JavaScript

const {isolatedUser}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
fs.mkdirSync('test-results',{recursive:true});
(async()=>{
const browser=await require('./support/browser.cjs').launchBrowser();
const errors=[];
async function login(name,secret){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill(secret);await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.ready&&a.user&&!a.busy&&a.workspace;});return page;}
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{
// Sandbox 6 belongs to this suite: an administrator (Alice Morgan) and one employee (Bob Chen).
const sandbox=await isolatedUser({sandbox:6,prefix:'cover-lead',display:'Alice Morgan',employees:['Bob Chen']});
const [bobAccount]=sandbox.staff,team=sandbox.workspace;
alice=await login(sandbox.username,sandbox.password);bob=await login(bobAccount.username,bobAccount.password);
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.display_name==='Bob Chen').user.id};});
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);const r=(await a.api(endpoint)).workspace;const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
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;});