NEXT
Checks / test (push) Failing after 2m25s

This commit is contained in:
Ultradesu committed 2026-10-07 15:30:31 +01:00
1 parent e37d583179
commit 5260c2828b
65 files changed
+8469 -380

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
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.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.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');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()=>{
+53
View File
@@ -0,0 +1,53 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
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 launchBrowser();
try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const username='account-person-'+Date.now(),email=username+'@example.com';
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill(username);
await page.getByLabel('Display name',{exact:true}).fill('Account person');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
const emailInput=page.getByLabel('Email address',{exact:true});
assert.equal(await emailInput.evaluate(e=>e.required),true);
await page.getByRole('button',{name:'Create account',exact:true}).click();
assert.equal(await emailInput.evaluate(e=>e.validity.valueMissing),true);
await emailInput.fill(email.toUpperCase());
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Account identity');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
const state=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {user:a.user,person:a.doc.people.find(p=>p.id===a.user.id)};});
assert.equal(state.user.email,email);
assert.equal(state.user.user_id,state.user.id);
assert.equal(state.person.email,email);
assert.equal(state.person.id,state.user.id);
await page.evaluate(()=>Alpine.$data(document.body).open('account'));
assert.equal(await dialog.getByLabel('Email address',{exact:true}).inputValue(),email);
assert.equal(await dialog.getByLabel('Email address',{exact:true}).isEditable(),false);
await dialog.getByLabel('Display name',{exact:true}).fill('Updated profile name');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.reload();
await page.waitForFunction(()=>Alpine.$data(document.body).doc?.people.length);
assert.equal(await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.doc.people.find(p=>p.id===a.user.id).display_name;}),'Updated profile name');
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.open('rotation');a.editPerson(a.user.id);});
const details=page.getByRole('region',{name:'Choose a person'});
assert.equal(await details.getByLabel('Email for JSM lookup',{exact:true}).inputValue(),email);
assert.equal(await details.getByLabel('Email for JSM lookup',{exact:true}).isEditable(),false);
await page.setViewportSize({width:390,height:844});
fs.mkdirSync('test-results',{recursive:true});
await page.screenshot({path:'test-results/account-person-mobile.png'});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
assert.deepEqual(errors,[]);
console.log('PASS: required email, normalized account-owned Person, profile persistence, readonly scheduling identity, mobile');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+49
View File
@@ -0,0 +1,49 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
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 launchBrowser();
try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('binding-path-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Binding path regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(()=>{
const a=Alpine.$data(document.body),root=a.newNode('dictionary'),owners=a.newNode('list',null,'owners');
owners.entries=[a.newNode('text','First owner'),a.newNode('text','Second owner')];root.entries=[owners];
const variable={id:crypto.randomUUID(),name:'Project',node:root};a.generationDraft.variables.push(variable);
a.editGenerationRule();const block=a.newBlock('rotation');block.fields.name={kind:'variable',variable:variable.id,path:[]};a.form.blocks=[block];
});
const open=()=>page.evaluate(()=>{const a=Alpine.$data(document.body);a.editBinding(a.form.blocks[0].fields.name,'text','Name');});
await open();
const popup=page.locator('.binding-popover'),path=popup.getByLabel('Nested field or position',{exact:true});
await path.pressSequentially('owners / ',{delay:30});
assert.equal(await path.inputValue(),'owners / ');
await path.pressSequentially('1',{delay:30});
await page.waitForFunction(()=>Alpine.$data(document.body).bindingValuePreview().text.includes('First owner'));
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).form.blocks[0].fields.name.path),['owners','1']);
await path.press('Backspace');await path.pressSequentially('2');
await page.waitForFunction(()=>Alpine.$data(document.body).bindingValuePreview().text.includes('Second owner'));
await popup.getByRole('button',{name:'Done',exact:true}).click();await open();
assert.equal(await path.inputValue(),'owners / 2');
await page.setViewportSize({width:390,height:844});
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/binding-path-mobile.png'});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
await popup.getByLabel('Value source',{exact:true}).selectOption('literal');
await popup.getByLabel('Value source',{exact:true}).selectOption('variable');
assert.equal(await path.inputValue(),'');
assert.deepEqual(errors,[]);
console.log('PASS: typed nested path, live resolved values, edits, reopening, mobile and source reset');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+1 -1
View File
@@ -20,7 +20,7 @@ fs.mkdirSync('test-results',{recursive:true});
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.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');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();
+8 -1
View File
@@ -29,7 +29,7 @@ fs.mkdirSync('test-results',{recursive:true});
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.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');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();
@@ -70,8 +70,15 @@ fs.mkdirSync('test-results',{recursive:true});
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();
await dialog().getByLabel('Field',{exact:true}).selectOption('priority');
await dialog().getByLabel('Operation',{exact:true}).selectOption('less-than');
await dialog().getByLabel('Value',{exact:true}).fill('P3');
await dialog().getByRole('checkbox',{name:'Not',exact:true}).check();
await page.screenshot({path:'test-results/routing-priority.png',fullPage:true});
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']);
assert.deepEqual((await state()).document.routes.find(r=>r.name==='Second').conditions,[{field:'priority',operation:'less-than',value:'P3',negate:true}]);
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']);
+53
View File
@@ -0,0 +1,53 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
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 launchBrowser();
try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('contact-approval-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Contact planner');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
const fixture=await page.evaluate(async()=>{
const a=Alpine.$data(document.body);
const r=await a.api('/api/workspaces','POST',{name:'Contact approval regression'});
const start=a.addDays(a.today(),2),contact=crypto.randomUUID();
const doc=r.document;
doc.people.push({id:contact,user_id:null,display_name:'Email colleague',email:'colleague@example.com',country:'CY'});
doc.schedules=[{id:crypto.randomUUID(),name:'Contact duty',type:'rotation',participants:[a.user.id],anchor:start,shift_days:7,handover:'11:00:00'}];
await a.api('/api/workspaces/'+r.id+'/config','PUT',{revision:r.revision,document:doc});
return {workspace:r.id,start,contact};
});
await page.goto(base+'/?workspace='+fixture.workspace);
await page.locator('[data-duty-date="'+fixture.start+'"]').click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Search colleagues').fill('colleague@example.com');
await dialog.getByRole('button',{name:'Email colleague',exact:true}).click();
const apply=dialog.getByRole('button',{name:'Apply now',exact:true});
await apply.waitFor();
await page.waitForFunction(()=>Alpine.$data(document.body).canSendRequest);
assert.equal(await apply.isEnabled(),true);
await dialog.getByText('Applies immediately. No approval needed.',{exact:true}).waitFor();
fs.mkdirSync('test-results',{recursive:true});
await page.screenshot({path:'test-results/contact-approval.png'});
await apply.click();
await dialog.waitFor({state:'hidden'});
const request=await page.evaluate(()=>Alpine.$data(document.body).doc.requests.at(-1));
assert.equal(request.status,'applied');
assert.equal(request.forced_by,null);
assert.deepEqual(request.required_approvers,[]);
await page.reload();
await page.waitForFunction(()=>Alpine.$data(document.body).doc?.requests.length);
assert.match(await page.locator('[data-duty-date="'+fixture.start+'"]').innerText(),/Email/);
assert.equal(await page.evaluate(id=>Alpine.$data(document.body).doc.requests.at(-1).legs[0].to_user,fixture.contact),fixture.contact);
assert.deepEqual(errors,[]);
console.log('PASS: email contact preview, immediate unforced save, calendar persistence, no JavaScript errors');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+92 -10
View File
@@ -1,13 +1,95 @@
const assert=require('node:assert/strict');
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();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({patLine 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});
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
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 launchBrowser();
try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('map-origin-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Layered map regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(async()=>{
const a=Alpine.$data(document.body),doc=structuredClone(Alpine.raw(a.doc));
const manual={id:crypto.randomUUID(),name:'Manual fallback',type:'rotation',participants:[a.user.id],anchor:a.today(),handover:'11:00:00',shift_days:7};doc.schedules.push(manual);
await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();
const projects=a.newNode('list');for(const name of ['cse','opd']){const row=a.newNode('dictionary'),owners=a.newNode('list',null,'owners');owners.entries=[a.newNode('person',a.user.id)];row.entries=[a.newNode('text',name,'name'),owners];projects.entries.push(row);}
const v={id:crypto.randomUUID(),name:'projects',node:projects},later={id:crypto.randomUUID(),name:'later',node:a.newNode('text','Later schedule')};
a.generationDraft.variables=[later,v];
const loop={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:v.id,path:[]}};
const block=a.newBlock('priority');block.fields.name={kind:'item',iteration:loop.id,path:['name'],prefix:'',suffix:' daytime'};block.fields.participants={kind:'item',iteration:loop.id,path:['owners']};block.fields.fallback=a.literal(manual.id);
const policy=a.newBlock('escalation');policy.fields.name={kind:'item',iteration:loop.id,path:['name'],suffix:' escalation'};policy.steps[0].recipient={kind:'output',output:block.id};
const route=a.newBlock('route');route.fields.name={kind:'item',iteration:loop.id,path:['name'],suffix:' route'};route.fields.escalation={kind:'output',output:policy.id};
const second=a.newBlock('rotation');second.fields.name={kind:'variable',variable:later.id,path:[]};
a.generationDraft.rules=[{id:crypto.randomUUID(),name:'Project owners',enabled:true,iterations:[loop],when:null,blocks:[block,policy,route]},{id:crypto.randomUUID(),name:'Later generator',enabled:true,iterations:[],when:null,blocks:[second]}];
await a.openGenerationMap();
});
await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);
const map=page.locator('dialog.generation-map'),canvas=map.locator('.map-canvas');
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).mapSnapshot.report.issues),[]);
const layout=await page.evaluate(()=>Alpine.$data(document.body).mapNodes.map(n=>({id:n.id,lane:n.lane,title:n.title,x:n.position.x,y:n.position.y,manual:n.manual,step:n.step})));
assert(layout.find(n=>n.title==='projects').x<layout.find(n=>n.title==='later').x);
assert(Math.max(...layout.filter(n=>n.lane===0).map(n=>n.y))<Math.min(...layout.filter(n=>n.lane===1).map(n=>n.y)));
assert(Math.max(...layout.filter(n=>n.lane===1).map(n=>n.y))<Math.min(...layout.filter(n=>n.lane===2).map(n=>n.y)));
assert(layout.some(n=>n.manual&&n.title==='Manual fallback'));
const shapes=await canvas.evaluate(el=>{const cy=el._cyreg.cy;return ['variable','rule','output'].map(k=>cy.nodes('[kind="'+k+'"]').first().style('shape'));});
assert.equal(new Set(shapes).size,3);
assert.equal(await canvas.evaluate(el=>new Set(['schedule','escalation','route'].map(k=>el._cyreg.cy.nodes('[resourceKind="'+k+'"]').first().style('background-color'))).size),3);
const address=map.locator('.map-source-addresses .map-address').filter({has:page.getByRole('button',{name:'name',exact:true})});
await address.getByRole('button',{name:'name',exact:true}).click();
const probe=page.locator('.map-probe');await probe.waitFor();
assert((await probe.innerText()).includes('cse'));assert((await probe.innerText()).includes('opd'));
const chain=probe.locator('.map-probe-chain');
assert.equal(await chain.getByRole('button',{name:'View source',exact:true}).count(),0);
await chain.getByRole('button',{name:'Project.value',exact:true}).click();
assert((await probe.locator('.map-probe-example').first().innerText()).includes('Path tester'));
await chain.getByRole('button',{name:'name',exact:true}).click();
assert(!(await probe.locator('.map-probe-example').first().innerText()).includes('Path tester'));
assert.equal(await chain.locator('[aria-current="step"]').innerText(),'name');
assert((await chain.getByRole('button',{name:'name',exact:true}).evaluate(el=>getComputedStyle(el).fontFamily)).includes('monospace'));
await probe.getByRole('button',{name:'Done',exact:true}).click();
const resource=map.locator('.map-resource').filter({has:page.locator('.map-resource-name').filter({hasText:'cse daytime'})});
await resource.locator('.map-resource-name button').filter({hasText:'cse'}).click();await probe.waitFor();
assert((await probe.locator('.map-address').textContent()).includes('Project.value.name'));
await probe.locator('.map-probe-actions').getByRole('button',{name:'View source',exact:true}).click();
await probe.waitFor({state:'hidden'});
await resource.locator('summary').click();
await resource.getByRole('button',{name:'Path tester',exact:true}).click();await probe.waitFor();
assert((await probe.locator('.map-address').textContent()).includes('Project.value.owners'));
await probe.locator('.map-probe-chain').getByRole('button',{name:'var.projects',exact:true}).click();
await probe.getByRole('button',{name:'Back',exact:true}).click();
assert((await probe.locator('.map-address').textContent()).includes('Project.value.owners'));
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/map-provenance-desktop.png'});
await probe.getByRole('button',{name:'Done',exact:true}).click();
const policyResource=map.locator('.map-resource').filter({has:page.locator('.map-resource-name').filter({hasText:'cse escalation'})});
await policyResource.locator('summary').click();
await policyResource.getByRole('button',{name:'cse daytime',exact:true}).click();
await probe.locator('.map-probe-fields').getByRole('button',{name:'Name',exact:true}).click();
assert((await probe.innerText()).includes('cse daytime'));
await probe.getByRole('button',{name:'Done',exact:true}).click();
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.mapSelect(a.mapNodes.find(n=>n.manual).id);});
await map.getByText('Manual resources · 1',{exact:true}).waitFor();
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.mapSelect(a.mapNodes.find(n=>n.kind==='iteration').id);a.mapToggle();});
assert(await page.evaluate(()=>Alpine.$data(document.body).mapNodes.some(n=>n.id.startsWith('o:'))));
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).mapAddressParts('{ var["a.b"].owners[1] }').map(p=>p.text)),['var["a.b"]','.','owners[1]']);
await map.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 map.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-mobile.png'});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
await page.setViewportSize({width:1280,height:900});await page.mouse.click(2,2);await map.waitFor({state:'hidden'});
assert.deepEqual(errors,[]);
console.log('PASS: layers, first-use ordering, shapes, manual resources, provenance segments, people, history, groups, mobile and backdrop');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+46
View File
@@ -0,0 +1,46 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
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 launchBrowser();
try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('generation-team-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Team escalation regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editGenerationRule();a.form.name='Team escalation';a.form.blocks=[a.newBlock('escalation')];});
await dialog.getByLabel('Recipient type',{exact:true}).selectOption('team_all');
await dialog.getByLabel('Delay in minutes',{exact:true}).fill('30');
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading && a.generationResult?.result?.escalations.some(e=>e.steps.some(s=>s.recipient.type==='team_all' && s.delay_minutes===30));});
assert.equal(await dialog.locator('.generation-step .binding-button.grow:visible').count(),0);
await dialog.getByLabel('Recipient type',{exact:true}).selectOption('team_random');
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading && a.generationResult?.result?.escalations.some(e=>e.steps.some(s=>s.recipient.type==='team_random' && s.delay_minutes===30));});
assert.equal(await dialog.locator('.generation-step .binding-button.grow:visible').count(),0);
await dialog.getByRole('button',{name:'Use in draft',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationSaving && !a.generationUnsaved();});
await page.reload();
await page.waitForFunction(()=>Alpine.$data(document.body).generationDraft?.rules.length);
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editGenerationRule(a.generationDraft.rules[0]);});
assert.equal(await dialog.getByLabel('Recipient type',{exact:true}).inputValue(),'team_random');
assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'30');
await page.setViewportSize({width:390,height:844});
await dialog.getByLabel('Recipient type',{exact:true}).scrollIntoViewIfNeeded();
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/generation-team-mobile.png'});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
assert.deepEqual(errors,[]);
console.log('PASS: generated team recipient, 30-minute delay, server expansion, shared persistence and mobile');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+3 -3
View File
@@ -43,7 +43,7 @@ fs.mkdirSync('test-results',{recursive:true});
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:'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.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');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();
@@ -57,9 +57,9 @@ fs.mkdirSync('test-results',{recursive:true});
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();
await page.getByRole('button',{name:'Save 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 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:'Save 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();
+101
View File
@@ -0,0 +1,101 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser,openSetup}=require('./support/browser.cjs');
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 launchBrowser();
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});
const page=await context.newPage();page.setDefaultTimeout(15000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const suffix=Date.now(),username='sync-owner-'+suffix,password='local-workflow-test-password';
const colleague='sync-colleague-'+suffix;
try{
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill(username);
await page.getByLabel('Display name',{exact:true}).fill('Workflow owner');
await page.getByLabel('Password',{exact:true}).fill(password);
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.waitForFunction(()=>Alpine.$data(document.body).user && !Alpine.$data(document.body).busy);
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await page.getByRole('dialog').getByLabel('Name',{exact:true}).fill('Shared publication test');
await page.getByRole('dialog').getByRole('button',{name:'Save changes',exact:true}).click();
await page.getByRole('dialog').waitFor({state:'hidden'});
const workspace=await page.evaluate(()=>Alpine.$data(document.body).workspace.id);
const secondContext=await browser.newContext({locale:'en-US'});
const registered=await secondContext.request.post(base+'/api/register',{headers:{Origin:base},data:{username:colleague,email:colleague+'@example.com',password,display_name:'Workflow colleague'}});assert.equal(registered.status(),200);
await page.evaluate(async colleague=>{const a=Alpine.$data(document.body);await a.api(a.endpoint('/members'),'PUT',{revision:a.workspace.revision,username:colleague,permissions:['view','edit_schedules','manage_integration']});await a.loadWorkspace();const doc=structuredClone(Alpine.raw(a.doc));doc.schedules.push({id:crypto.randomUUID(),name:'Awaiting publication',type:'rotation',participants:[a.user.id],anchor:new Date().toISOString().slice(0,10),shift_days:7,handover:'11:00:00'});await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();},colleague);
const review=page.locator('[data-publication-review]');
await review.getByText('+1 new',{exact:true}).waitFor();
const initialSync=await page.evaluate(()=>structuredClone(Alpine.raw(Alpine.$data(document.body).detail.sync)));
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.detail.sync={...a.detail.sync,state:'publishing',publication_busy:true,lease_until:new Date(Date.now()+300000).toISOString()};});
assert(await review.locator('.publication-sync').isDisabled());
await page.evaluate(()=>{Alpine.$data(document.body).detail.sync.publication_busy=false;});
await page.waitForFunction(()=>!document.querySelector('.publication-sync').disabled);
await review.getByText('The publication lock has expired. The worker can resume; you can also request synchronization again.',{exact:true}).waitFor();
await page.screenshot({path:'test-results/manual-sync-expired-lease.png',fullPage:true});
await page.evaluate(sync=>{Alpine.$data(document.body).detail.sync=sync;},initialSync);
assert(await review.locator('.publication-sync').evaluate(el=>el.classList.contains('sync-needed')));
await openSetup(page,'Variables & rules');
await page.getByRole('button',{name:'+ Variable',exact:true}).click();
await page.getByLabel('Variable name',{exact:true}).fill('Shared unsynchronized draft');
await page.getByRole('button',{name:'Use in draft',exact:true}).click();
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.detail.generation_draft?.revision>0 && !a.generationSaving && !a.generationLoading;});
await review.getByRole('button',{name:'Reload saved state',exact:true}).waitFor();
await review.getByRole('button',{name:'Save checked changes',exact:true}).waitFor();
page.once('dialog',dialog=>dialog.dismiss());
await page.getByRole('button',{name:'Remove variable Shared unsynchronized draft',exact:true}).click();
await page.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
await page.getByRole('button',{name:'+ Variable',exact:true}).click();
await page.getByLabel('Variable name',{exact:true}).fill('Delete confirmation test');
await page.getByRole('button',{name:'Use in draft',exact:true}).click();
page.once('dialog',dialog=>dialog.accept());
await page.getByRole('button',{name:'Remove variable Delete confirmation test',exact:true}).click();
await page.getByRole('button',{name:'Edit variable Delete confirmation test',exact:true}).waitFor({state:'hidden'});
await page.evaluate(()=>{const a=Alpine.$data(document.body),block=a.newBlock('rotation');block.fields.name=a.literal('Generated pending schedule');a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Shared rotation rule',enabled:true,iterations:[],when:null,blocks:[block]});});
await review.getByText('+2 new',{exact:true}).waitFor();
// More than one worker tick passes; saving has not authorized a publication.
await page.waitForTimeout(6000);
const before=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).sync;});assert.equal(before.published_revision,null);assert.equal(before.authorized_revision,null);
await page.getByRole('button',{name:'Sign out',exact:true}).click();
await page.getByRole('button',{name:'Already registered? Sign in',exact:true}).click();
await page.getByLabel('Username',{exact:true}).fill(username);await page.getByLabel('Password',{exact:true}).fill(password);
await page.getByRole('button',{name:'Sign in',exact:true}).click();await openSetup(page,'Variables & rules');
await page.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
const second=await secondContext.newPage();second.on('pageerror',e=>errors.push(e.message));
await second.goto(base+'/?workspace='+workspace+'&view=configuration');
await second.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
await second.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);
await second.screenshot({path:'test-results/manual-sync-shared-desktop.png',fullPage:true});
const secondReview=second.locator('[data-publication-review]');
let holdQueued=true;
const workspaceUrl=base+'/api/workspaces/'+workspace;
await second.route(workspaceUrl,async route=>{const response=await route.fetch();const data=await response.json();if(holdQueued)data.sync={...data.sync,state:'pending',authorized_revision:data.workspace?.revision||1,publication_busy:false,lease_until:null};await route.fulfill({response,json:data});});
await secondReview.locator('.publication-sync').click();
await second.waitForFunction(()=>!Alpine.$data(document.body).busy);
assert.equal(await second.evaluate(()=>Alpine.$data(document.body).error),'');
assert(await secondReview.locator('.publication-sync').isDisabled());
await secondReview.getByRole('button',{name:'Queued…',exact:true}).waitFor();
await secondReview.getByText('Synchronization requested. Waiting for the worker; status updates automatically.',{exact:true}).waitFor();
await second.screenshot({path:'test-results/manual-sync-queued.png',fullPage:true});
holdQueued=false;
await second.waitForFunction(()=>Alpine.$data(document.body).detail.sync.state==='synced');
const after=await second.evaluate(()=>Alpine.$data(document.body).detail.sync);
assert.equal(after.state,'synced');assert.equal(after.has_changes,false);
assert(!await secondReview.locator('.publication-sync').evaluate(el=>el.classList.contains('sync-needed')));
assert.equal(await second.evaluate(()=>Alpine.$data(document.body).doc.schedules.length),2);
await second.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);
await second.setViewportSize({width:390,height:844});
await second.evaluate(()=>{Alpine.$data(document.body).setupOpen=false;});
await second.waitForFunction(()=>getComputedStyle(document.querySelector('.publication-sync')).backgroundColor==='rgb(255, 255, 255)');
await second.screenshot({path:'test-results/manual-sync-mobile.png',fullPage:true});
assert(await second.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
assert.deepEqual(errors,[]);
console.log('Shared draft, no automatic publication, colleague sync, published review and mobile layout passed.');
await secondContext.close();
}catch(e){await page.screenshot({path:'test-results/manual-sync-failure.png',fullPage:true});console.error('Browser errors:',errors);console.error(await page.locator('body').innerText());throw e;}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exit(1);});
+74
View File
@@ -0,0 +1,74 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
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 launchBrowser();
try {
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
page.setDefaultTimeout(12000);
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('config-transfer-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Portable config regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.view='generation';});
const initial=await page.evaluate(()=>({revision:Alpine.$data(document.body).workspace.revision,workspace:Alpine.$data(document.body).workspace.id}));
await page.getByRole('button',{name:'Import / Export',exact:true}).click();
const modal=page.getByRole('dialog');
await modal.getByLabel('Configuration · meguri/v2 JSON').waitFor();
await page.waitForFunction(()=>!!Alpine.$data(document.body).transferExport);
await modal.getByRole('button',{name:'Guide',exact:true}).click();
const guide=modal.locator('.config-guide');await guide.waitFor({state:'visible'});
await page.context().grantPermissions(['clipboard-read','clipboard-write'],{origin:base});
await modal.getByRole('button',{name:'Copy full guide',exact:true}).click();
const copiedGuide=await page.evaluate(()=>navigator.clipboard.readText());
assert(copiedGuide.includes('$lookup'));assert(copiedGuide.includes('not_equals'));assert(copiedGuide.includes('ends-with'));
const sample=JSON.parse(copiedGuide.match(/```json\n([\s\S]*?)\n```/)[1]);
const guidePreview=await page.evaluate(async text=>{const a=Alpine.$data(document.body);return a.api(a.endpoint('/generation-import-preview'),'POST',{...a.transferBase,text});},JSON.stringify(sample));
assert.deepEqual(guidePreview.preview.issues,[]);assert.equal(guidePreview.preview.changes.filter(c=>c.action==='create').length,4);
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/config-guide-desktop.png'});
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/config-guide-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1280,height:900});
await modal.getByRole('button',{name:'Import',exact:true}).click();
const input=modal.getByLabel('Paste or edit configuration'),apply=modal.getByRole('button',{name:'Apply to shared draft',exact:true});
await input.fill('{ broken');
await modal.getByRole('button',{name:'Guide',exact:true}).click();await modal.getByRole('button',{name:'Import',exact:true}).click();assert.equal(await input.inputValue(),'{ broken');
await modal.getByRole('alert').filter({hasText:'Invalid configuration'}).waitFor();assert(await apply.isDisabled());
const config={format:'meguri/v1',variables:[{name:'projects',node:{kind:'list',entries:['cse','opd'].map(name=>({id:name,kind:'dictionary',entries:[{kind:'text',key:'name',value:name},{kind:'list',key:'owners',entries:[{kind:'person',value:'owner@example.com'}]}]}))}},{name:'keep me',node:{kind:'text',value:'retained'}}],generators:[{name:'Weekly projects',iterations:[{name:'Project',source:{kind:'variable',variable:'projects'}}],blocks:[{id:'weekly',kind:'rotation',fields:{name:{kind:'item',iteration:'Project',path:['name'],suffix:' on-call'},participants:{kind:'item',iteration:'Project',path:['owners']},anchor:{kind:'literal',value:'2026-09-28'},shift_days:{kind:'literal',value:7},handover:{kind:'literal',value:'11:00'}}}]}]};
await input.fill(JSON.stringify(config,null,2));
await page.waitForFunction(()=>Alpine.$data(document.body).transferCanApply());
assert((await modal.locator('.transfer-preview').innerText()).includes('cse on-call'));
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).transferResult.contacts.map(p=>p.email)),['owner@example.com']);
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).workspace.revision),initial.revision);
const before=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).generation_draft;});assert(!before.payload||!before.payload.rules.length);
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/config-import-desktop.png'});
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/config-import-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1280,height:900});
await apply.click();await modal.waitFor({state:'hidden'});
await page.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).workspace.revision),initial.revision);
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.length),0);
const ids=await page.evaluate(()=>Alpine.$data(document.body).generationDraft.rules.map(r=>r.blocks.map(b=>b.id)));
await page.getByRole('button',{name:'Import / Export',exact:true}).click();await page.waitForFunction(()=>!!Alpine.$data(document.body).transferExport);
const exported=await modal.getByLabel('Configuration · meguri/v2 JSON').inputValue();assert(exported.includes('owner@example.com'));assert.equal(JSON.parse(exported).format,'meguri/v2');assert(!exported.includes(ids[0][0]));await page.screenshot({path:'test-results/config-v2-export.png'});
const downloaded=page.waitForEvent('download');await modal.getByRole('button',{name:'Download JSON',exact:true}).click();assert.equal((await downloaded).suggestedFilename(),'meguri-config.json');
const edited=JSON.parse(exported);delete edited.variables['keep me'];edited.generators[0].outputs[0].shift_days=14;
await modal.getByRole('button',{name:'Import',exact:true}).click();await input.fill(JSON.stringify(edited));await page.waitForFunction(()=>Alpine.$data(document.body).transferCanApply());
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).transferResult.preview.changes.filter(c=>c.action==='create').length),2);
await apply.click();await modal.waitFor({state:'hidden'});
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.rules.map(r=>r.blocks.map(b=>b.id))),ids);
assert(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.some(v=>v.name==='keep me')));
await page.reload();await page.waitForFunction(()=>Alpine.$data(document.body).generationDraft?.rules.length===1);
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.rules[0].blocks[0].fields.shift_days.value),14);
assert.deepEqual(errors,[]);
console.log('PASS: portable config live preview, parse errors, no writes before apply, shared draft persistence, identities, merge, export, download and mobile');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+65
View File
@@ -0,0 +1,65 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3011';
const username=process.env.MEGURI_BROWSER_SITE_USERNAME;
assert(username,'Set MEGURI_BROWSER_SITE_USERNAME to a dedicated test administrator');
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{
const browser=await launchBrowser();
try{
const context=await browser.newContext({locale:'en-US',viewport:{width:1400,height:1050}});
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
await page.goto(base+'/?view=site');
await page.getByLabel('Username',{exact:true}).fill(username);
await page.getByLabel('Password',{exact:true}).fill(process.env.MEGURI_BROWSER_SITE_PASSWORD||'local-browser-test-password');
await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('link',{name:'Site administration',exact:true}).click();
await page.waitForFunction(()=>Alpine.$data(document.body).siteData);
const root=page.locator('.site-admin');
await root.getByLabel('Search',{exact:true}).fill(username);
await root.getByRole('button',{name:'Edit',exact:true}).click();
await root.getByLabel('Display name',{exact:true}).fill('Administration browser test');
await root.getByRole('button',{name:'Save changes',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy&&!Alpine.$data(document.body).siteEdit);
await root.getByRole('button',{name:'Authentication',exact:true}).click();
assert(await root.getByText(/Configuration draft only/).isVisible());
await root.getByLabel('Enable OIDC single sign-on',{exact:true}).check();
await root.getByLabel('Allow password login',{exact:true}).uncheck();
await root.getByLabel('Issuer URL',{exact:true}).fill('https://identity.example.com/realms/company');
await root.getByLabel('Client ID',{exact:true}).fill('meguri-browser-test');
await root.getByLabel('Login button label',{exact:true}).fill('Company SSO');
await root.getByRole('button',{name:'Save configuration draft',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy);
assert.equal(await page.locator('.error:visible').count(),0);
await page.reload();await page.waitForFunction(()=>Alpine.$data(document.body).siteData);
await root.getByRole('button',{name:'Authentication',exact:true}).click();
assert.equal(await root.getByLabel('Login button label',{exact:true}).inputValue(),'Company SSO');
assert.equal(await root.getByLabel('Allow password login',{exact:true}).isChecked(),false);
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/site-admin-desktop.png',fullPage:true});
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/site-admin-mobile.png',fullPage:true});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
// Current password authentication stays active despite the saved draft.
const guest=await browser.newContext({locale:'en-US'});const other=await guest.newPage();await other.goto(base);
await other.getByLabel('Username',{exact:true}).fill(username);await other.getByLabel('Password',{exact:true}).fill(process.env.MEGURI_BROWSER_SITE_PASSWORD||'local-browser-test-password');await other.getByRole('button',{name:'Sign in',exact:true}).click();await other.waitForFunction(()=>Alpine.$data(document.body).user);
// Non-admins cannot enumerate the site, even with direct requests.
const ordinary=await browser.newContext();const userPage=await ordinary.newPage();await userPage.goto(base);
const denied=await userPage.evaluate(async()=>{const username='site-member-'+Date.now();const r=await fetch('/api/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,email:username+'@example.com',display_name:'Ordinary user',password:'local-browser-test-password'})});if(!r.ok)throw new Error('signup');return (await fetch('/api/site')).status;});assert.equal(denied,403);
const created=await userPage.evaluate(async()=>{const session=await (await fetch('/api/session')).json();const result=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':session.csrf},body:JSON.stringify({name:'Site UI workspace '+Date.now()})});if(!result.ok)throw new Error('workspace');return {workspace:await result.json(),user:session.user};});
await page.setViewportSize({width:1400,height:1050});
await root.getByRole('button',{name:'Refresh',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).siteLoading);
await root.getByRole('button',{name:'Workspaces',exact:true}).click();
await root.getByLabel('Search',{exact:true}).fill('Site UI workspace');
await root.getByRole('button',{name:'Edit',exact:true}).last().click();
await root.getByLabel('Name',{exact:true}).fill('Site UI renamed workspace');
await root.getByLabel('Grant owner access to',{exact:true}).selectOption({label:username});
await root.getByRole('button',{name:'Save changes',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy&&!Alpine.$data(document.body).siteEdit);
assert.equal(await page.locator('.error:visible').count(),0);
assert(await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.some(w=>w.name==='Site UI renamed workspace')));
assert.deepEqual(errors,[]);
await context.close();await guest.close();await ordinary.close();
console.log('PASS: site navigation, profile editing, persisted OIDC draft, unchanged password login, denied non-admin access, desktop/mobile');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+437
View File
@@ -0,0 +1,437 @@
{
"format": "meguri/v1",
"generators": [
{
"blocks": [
{
"fields": {
"anchor": {
"kind": "literal",
"value": "2026-09-28"
},
"handover": {
"kind": "literal",
"value": "11:00"
},
"name": {
"kind": "literal",
"value": "Time off default"
},
"participants": {
"kind": "variable",
"value": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003"
],
"variable": "on_call"
},
"shift_days": {
"kind": "literal",
"value": 7
}
},
"id": "00000000-0000-0000-0000-000000000004",
"kind": "rotation"
}
],
"enabled": true,
"name": "Time off"
},
{
"blocks": [
{
"fields": {
"fallback": {
"kind": "output",
"output": [
"Time off",
"00000000-0000-0000-0000-000000000004"
],
"value": null
},
"handover": {
"kind": "literal",
"value": "11:00"
},
"name": {
"iteration": "Project",
"kind": "item",
"path": [
"name"
],
"suffix": " daytime primary",
"value": "New First available"
},
"participants": {
"iteration": "Project",
"kind": "item",
"path": [
"primary"
],
"value": [
"00000000-0000-0000-0000-000000000001"
]
}
},
"id": "00000000-0000-0000-0000-000000000005",
"kind": "priority"
},
{
"fields": {
"fallback": {
"kind": "output",
"output": [
"Time off",
"00000000-0000-0000-0000-000000000004"
],
"value": null
},
"handover": {
"kind": "literal",
"value": "11:00"
},
"name": {
"iteration": "Project",
"kind": "item",
"path": [
"name"
],
"suffix": " daytime secondary",
"value": "New First available"
},
"participants": {
"iteration": "Project",
"kind": "item",
"path": [
"secondary"
],
"value": [
"00000000-0000-0000-0000-000000000001"
]
}
},
"id": "00000000-0000-0000-0000-000000000006",
"kind": "priority"
},
{
"fields": {
"name": {
"iteration": "Project",
"kind": "item",
"path": [
"name"
],
"suffix": " - daytime",
"value": "MG: { iteration[\"Item 1\"].value.name } - owners"
},
"repeat_count": {
"kind": "literal",
"value": 0
},
"repeat_wait": {
"kind": "literal",
"value": 0
}
},
"id": "00000000-0000-0000-0000-000000000007",
"kind": "escalation",
"steps": [
{
"condition": "if-not-acked",
"delay": {
"kind": "literal",
"value": 0
},
"recipient": {
"kind": "output",
"output": [
"Owners during working hours",
"00000000-0000-0000-0000-000000000005"
],
"value": null
},
"team_all": false,
"user": false
},
{
"condition": "if-not-acked",
"delay": {
"kind": "literal",
"value": 10
},
"recipient": {
"kind": "output",
"output": [
"Owners during working hours",
"00000000-0000-0000-0000-000000000006"
],
"value": null
},
"team_all": false,
"user": false
},
{
"condition": "if-not-acked",
"delay": {
"kind": "literal",
"value": 30
},
"recipient": {
"key": {
"iteration": "Project",
"kind": "item",
"value": null
},
"kind": "literal",
"output": [
"Time off",
"00000000-0000-0000-0000-000000000004"
],
"value": null
},
"team_all": true,
"user": false
}
]
},
{
"conditions": [
{
"field": "tags",
"negate": false,
"operation": "contains",
"value": {
"iteration": "Project",
"kind": "item",
"path": [
"tags"
],
"value": ""
}
},
{
"field": "priority",
"negate": true,
"operation": "less-than",
"value": {
"iteration": "Project",
"kind": "literal",
"value": "P3"
}
}
],
"fields": {
"escalation": {
"kind": "output",
"output": [
"Owners during working hours",
"00000000-0000-0000-0000-000000000007"
],
"value": null
},
"name": {
"iteration": "Project",
"kind": "item",
"path": [
"name"
],
"suffix": " - daytime route",
"value": "New Routing rule"
},
"working": {
"kind": "literal",
"value": true
}
},
"id": "00000000-0000-0000-0000-000000000008",
"kind": "route"
}
],
"enabled": true,
"iterations": [
{
"name": "Project",
"source": {
"kind": "variable",
"value": null,
"variable": "projects"
}
}
],
"name": "Owners during working hours"
}
],
"variables": [
{
"name": "on_call",
"node": {
"entries": [
{
"id": "00000000-0000-0000-0000-000000000009",
"kind": "person",
"value": "alice@example.com"
},
{
"id": "00000000-0000-0000-0000-00000000000a",
"kind": "person",
"value": "bob@example.com"
},
{
"id": "00000000-0000-0000-0000-00000000000b",
"kind": "person",
"value": "carol@example.com"
}
],
"id": "00000000-0000-0000-0000-00000000000c",
"kind": "list",
"value": ""
}
},
{
"name": "projects",
"node": {
"entries": [
{
"entries": [
{
"id": "00000000-0000-0000-0000-00000000000d",
"key": "name",
"kind": "text",
"value": "alpha"
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-00000000000e",
"kind": "person",
"value": "bob@example.com"
}
],
"id": "00000000-0000-0000-0000-00000000000f",
"key": "primary",
"kind": "list",
"value": null
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-000000000010",
"kind": "person",
"value": "alice@example.com"
}
],
"id": "00000000-0000-0000-0000-000000000011",
"key": "secondary",
"kind": "list",
"value": null
},
{
"id": "00000000-0000-0000-0000-000000000012",
"key": "tags",
"kind": "text",
"value": "alpha"
}
],
"id": "00000000-0000-0000-0000-000000000013",
"kind": "dictionary",
"value": null
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-000000000014",
"key": "name",
"kind": "text",
"value": "beta"
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-000000000015",
"kind": "person",
"value": "alice@example.com"
}
],
"id": "00000000-0000-0000-0000-000000000016",
"key": "primary",
"kind": "list",
"value": null
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-000000000017",
"kind": "person",
"value": "carol@example.com"
}
],
"id": "00000000-0000-0000-0000-000000000018",
"key": "secondary",
"kind": "list",
"value": null
},
{
"id": "00000000-0000-0000-0000-000000000019",
"key": "tags",
"kind": "text",
"value": "beta"
}
],
"id": "00000000-0000-0000-0000-00000000001a",
"kind": "dictionary",
"value": ""
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-00000000001b",
"key": "name",
"kind": "text",
"value": "gamma"
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-00000000001c",
"kind": "person",
"value": "alice@example.com"
}
],
"id": "00000000-0000-0000-0000-00000000001d",
"key": "primary",
"kind": "list",
"value": null
},
{
"entries": [
{
"id": "00000000-0000-0000-0000-00000000001e",
"kind": "person",
"value": "carol@example.com"
}
],
"id": "00000000-0000-0000-0000-00000000001f",
"key": "secondary",
"kind": "list",
"value": null
},
{
"id": "00000000-0000-0000-0000-000000000020",
"key": "tags",
"kind": "text",
"value": "^(gamma|project:gamma)(-[\\w-]+)?$"
}
],
"id": "00000000-0000-0000-0000-000000000021",
"kind": "dictionary",
"value": ""
}
],
"id": "00000000-0000-0000-0000-000000000022",
"kind": "list",
"value": ""
}
}
]
}
+104
View File
@@ -688,3 +688,107 @@ fn removed_fixed_assignee_is_rejected_in_documents_and_generation() {
example.rules[0].blocks[0].kind = "backup".into();
assert!(!example.expand().issues.is_empty());
}
#[test]
fn generated_team_step_uses_workspace_team_without_resolving_person() {
let mut e = Example::new();
let block = e
.rules
.iter_mut()
.flat_map(|r| &mut r.blocks)
.find(|b| b.kind == "escalation")
.unwrap();
let mut step = block.steps[0].clone();
assert!(
!step.team_all,
"Legacy steps keep their schedule or person recipient"
);
step.team_all = true;
step.delay = serde_json::from_value(json!({"kind":"literal","value":30})).unwrap();
step.recipient =
serde_json::from_value(json!({"kind":"variable","variable":Uuid::new_v4()})).unwrap();
step.condition = "if-not-acked".into();
block.steps.push(step);
let out = e.expand();
assert!(out.issues.is_empty(), "{:?}", out.issues);
let steps: Vec<_> = out
.document
.escalations
.iter()
.flat_map(|e| &e.steps)
.filter(|s| matches!(s.recipient, Recipient::TeamAll))
.collect();
assert!(!steps.is_empty());
for step in steps {
assert_eq!(step.delay_minutes, 30);
assert!(matches!(step.condition, EscalationCondition::IfNotAcked));
}
}
#[test]
fn provenance_preserves_iteration_paths_affixes_and_source_nodes() {
let mut e = Example::new();
(e.variables, e.rules) = demo::regional_platform(e.day, e.people[0], e.people[1]);
let out = e.expand();
assert!(out.issues.is_empty());
let rows = out.trace.iter().flat_map(|t| t["rows"].as_array().unwrap());
let mut saw_path = false;
for row in rows {
for trace in row["bindings"].as_array().unwrap() {
let binding = &trace["binding"];
if binding["kind"] == "item" && !binding["path"].as_array().unwrap().is_empty() {
saw_path = true;
assert!(!trace["parents"].as_array().unwrap().is_empty());
assert_eq!(
trace["stages"].as_array().unwrap().len(),
binding["path"].as_array().unwrap().len() + 1
);
let last = trace["stages"].as_array().unwrap().last().unwrap();
assert!(last["result"]["node_id"].is_string());
if let Some(text) = last["result"]["value"].as_str() {
assert_eq!(
trace["result"]["value"],
format!(
"{}{}{}",
binding["prefix"].as_str().unwrap(),
text,
binding["suffix"].as_str().unwrap()
)
);
}
}
}
}
assert!(saw_path);
}
#[test]
fn random_team_step_ignores_binding_and_rejects_conflicting_types() {
let mut e = Example::new();
let block = e
.rules
.iter_mut()
.flat_map(|r| &mut r.blocks)
.find(|b| b.kind == "escalation")
.unwrap();
block.steps[0].team_random = true;
block.steps[0].user = false;
block.steps[0].recipient =
serde_json::from_value(json!({"kind":"variable","variable":Uuid::new_v4()})).unwrap();
let out = e.expand();
assert!(out.issues.is_empty(), "{:?}", out.issues);
assert!(
out.document
.escalations
.iter()
.any(|p| matches!(p.steps[0].recipient, Recipient::TeamRandom))
);
e.rules
.iter_mut()
.flat_map(|r| &mut r.blocks)
.find(|b| b.kind == "escalation")
.unwrap()
.steps[0]
.team_all = true;
assert!(!e.expand().issues.is_empty());
}
+1741 -3
View File
File diff suppressed because it is too large. Load diff
+459
View File
@@ -0,0 +1,459 @@
use meguri::{demo, domain::*, generation, generation_service::Draft, portable};
use serde_json::json;
use uuid::Uuid;
fn fixture() -> (Uuid, WorkspaceDocument, Draft) {
let workspace = Uuid::new_v4();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let (variables, rules) = demo::project_matrix("2026-09-28".parse().unwrap(), a, b);
let mut doc = WorkspaceDocument::new("Portable".into());
for (id, email) in [(a, "alice@example.com"), (b, "bob@example.com")] {
doc.people.push(Person {
id,
user_id: None,
display_name: email.into(),
email: Some(email.into()),
country: Some("CY".into()),
});
}
(
workspace,
doc,
Draft {
revision: 1,
shared_revision: Some(0),
variables,
rules,
route_position: 0,
review_id: None,
},
)
}
#[test]
fn export_roundtrip_preserves_ids_people_bindings_and_omitted_inputs() {
let (ws, doc, draft) = fixture();
let source = portable::export_v1(&doc, &draft).unwrap();
let text = source.to_string();
for p in &doc.people {
assert!(!text.contains(&p.id.to_string()));
}
assert!(text.contains("alice@example.com"));
let (imported, changes) = portable::import(ws, &doc, &draft, &text).unwrap();
assert_eq!(
serde_json::to_value(&draft).unwrap(),
serde_json::to_value(&imported).unwrap()
);
assert!(
changes
.as_array()
.unwrap()
.iter()
.all(|c| c["action"] == "unchanged")
);
let empty = json!({"format":"meguri/v1","variables":[],"generators":[]});
let (retained, _) = portable::import(ws, &doc, &draft, &empty.to_string()).unwrap();
assert_eq!(
serde_json::to_value(retained).unwrap(),
serde_json::to_value(draft).unwrap()
);
}
#[test]
fn transfer_between_workspaces_remaps_and_repeat_import_is_stable() {
let (_, doc, draft) = fixture();
let source = portable::export_v1(&doc, &draft).unwrap().to_string();
let target = Uuid::new_v4();
let mut target_doc = doc.clone();
for p in &mut target_doc.people {
p.id = Uuid::new_v4();
}
let base = Draft {
variables: vec![],
rules: vec![],
..draft.clone()
};
let (first, _) = portable::import(target, &target_doc, &base, &source).unwrap();
assert_ne!(first.variables[0].id, draft.variables[0].id);
assert_ne!(first.rules[0].blocks[0].id, draft.rules[0].blocks[0].id);
let (second, changes) = portable::import(target, &target_doc, &first, &source).unwrap();
assert_eq!(
serde_json::to_value(&first).unwrap(),
serde_json::to_value(&second).unwrap()
);
assert!(
changes
.as_array()
.unwrap()
.iter()
.all(|c| c["action"] == "unchanged")
);
let result = generation::expand(
&target_doc,
target,
&first.variables,
&first.rules,
0,
&target_doc.working_interval,
&Default::default(),
chrono::Utc::now(),
);
assert!(result.issues.is_empty(), "{:?}", result.issues);
assert!(result.document.schedules.iter().all(|s| {
match &s.kind {
ScheduleKind::Rotation { participants, .. }
| ScheduleKind::Priority { participants, .. } => participants.iter().all(|id| {
result.document.people.iter().any(|p| p.id == *id)
&& !doc.people.iter().any(|p| p.id == *id)
}),
}
}));
}
#[test]
fn malformed_ambiguous_unknown_fields_and_missing_sources_are_rejected() {
let (ws, doc, draft) = fixture();
assert!(portable::import(ws, &doc, &draft, "{").is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["integration"] = json!({"token":"never-accepted"});
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["format"] = json!("meguri/v999");
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
let duplicate = v["variables"][0].clone();
v["variables"].as_array_mut().unwrap().push(duplicate);
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["generators"][0]["blocks"][0]["fields"]["participants"] =
json!({"kind":"variable","variable":"missing"});
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["generators"][0]["blocks"][0]["typo"] = json!(true);
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
}
#[test]
fn contacts_are_email_only_and_missing_export_email_blocks() {
let (ws, mut doc, draft) = fixture();
doc.people[0].email = None;
assert!(portable::export_v1(&doc, &draft).is_err());
let text = json!({"format":"meguri/v1","variables":[{"name":"new contact","node":{"kind":"person","value":"new@example.com"}}],"generators":[]});
let (out, _) = portable::import(ws, &doc, &draft, &text.to_string()).unwrap();
assert_eq!(out.variables.last().unwrap().node.value, "new@example.com");
assert_eq!(doc.people.len(), 2);
}
#[test]
fn manual_reference_is_mapped_by_exact_name_and_is_not_created() {
let (ws, mut doc, draft) = fixture();
let id = Uuid::new_v4();
doc.schedules.push(Schedule {
id,
name: "Fallback".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![doc.people[0].id],
anchor: "2026-09-28".parse().unwrap(),
shift_days: 7,
},
});
let config = json!({"format":"meguri/v1","variables":[],"generators":[{"name":"With fallback","blocks":[{"id":"owners","kind":"priority","fields":{"name":{"kind":"literal","value":"Owners"},"participants":{"kind":"literal","value":["alice@example.com"]},"fallback":{"kind":"literal","value":"Fallback"}}}]}]});
let (imported, _) = portable::import(ws, &doc, &draft, &config.to_string()).unwrap();
assert_eq!(
imported.rules.last().unwrap().blocks[0].fields["fallback"].value,
json!(id)
);
doc.schedules.clear();
assert!(portable::import(ws, &doc, &draft, &config.to_string()).is_err());
}
#[test]
fn blank_text_survives_export_import() {
let (ws, doc, mut draft) = fixture();
draft.variables.push(generation::Variable {
id: Uuid::new_v4(),
name: "blank".into(),
node: generation::Node {
id: Uuid::new_v4(),
kind: "text".into(),
key: String::new(),
value: json!(""),
entries: vec![],
},
});
let text = portable::export_v1(&doc, &draft).unwrap().to_string();
let (out, _) = portable::import(ws, &doc, &draft, &text).unwrap();
assert_eq!(out.variables.last().unwrap().node.value, "");
}
#[test]
fn complex_lookup_conditions_and_nested_paths_roundtrip() {
let (ws, doc, mut draft) = fixture();
for create in [demo::regional_platform, demo::service_tiers] {
let (variables, rules) = create(
"2026-09-28".parse().unwrap(),
doc.people[0].id,
doc.people[1].id,
);
draft.variables = variables;
draft.rules = rules;
let source = portable::export_v1(&doc, &draft).unwrap();
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
assert_eq!(
serde_json::to_value(out).unwrap(),
serde_json::to_value(&draft).unwrap()
);
}
}
#[test]
fn person_predicate_constants_transfer_by_email() {
let (ws, doc, mut draft) = fixture();
draft.rules[0].when = Some(generation::Predicate {
left: generation::Binding {
kind: "literal".into(),
value: json!(doc.people[0].id),
..Default::default()
},
operation: "equals".into(),
right: generation::Binding {
kind: "literal".into(),
value: json!(doc.people[1].id),
..Default::default()
},
});
let source = portable::export_v1(&doc, &draft).unwrap();
assert_eq!(
source["generators"][0]["when"]["left"]["value"]["$person"],
"alice@example.com"
);
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
assert_eq!(
out.rules[0].when.as_ref().unwrap().left.value,
json!(doc.people[0].id)
);
}
#[test]
fn compact_sample_preserves_resources_without_uuids_or_cached_binding_values() {
let (ws, doc, mut base) = fixture();
base.variables.clear();
base.rules.clear();
let source = include_str!("fixtures/portable-v1.json");
let (original, _) = portable::import(ws, &doc, &base, source).unwrap();
let compact = portable::export(&doc, &original).unwrap();
let text = serde_json::to_string_pretty(&compact).unwrap();
assert!(
text.len() * 3 < source.len(),
"{} vs {}",
text.len(),
source.len()
);
assert!(!text.contains("14e49"));
for r in &original.rules {
for b in &r.blocks {
assert!(!text.contains(&b.id.to_string()));
}
}
assert!(!text.contains("New First available"));
assert!(!text.contains("team_all"));
assert!(!text.contains("\"kind\""));
let (imported, _) = portable::import(ws, &doc, &original, &text).unwrap();
let now = chrono::Utc::now();
let expand = |draft: &Draft| {
generation::expand(
&doc,
ws,
&draft.variables,
&draft.rules,
0,
&doc.working_interval,
&Default::default(),
now,
)
};
let before = expand(&original);
let after = expand(&imported);
assert!(before.issues.is_empty(), "{:?}", before.issues);
assert!(after.issues.is_empty(), "{:?}", after.issues);
for (a, b) in [
(
json!(before.document.schedules),
json!(after.document.schedules),
),
(
json!(before.document.escalations),
json!(after.document.escalations),
),
(json!(before.document.routes), json!(after.document.routes)),
] {
assert_eq!(a, b);
}
let (again, _) = portable::import(ws, &doc, &imported, &text).unwrap();
assert_eq!(json!(imported), json!(again));
}
#[test]
fn compact_types_order_escapes_and_output_keys_survive_reordering() {
let (ws, doc, base) = fixture();
let value = json!({"format":"meguri/v2","variables":{"typed":{"$object":[["z",{"$date":"2026-10-01"}],["a",{"$time":"09:00"}],["duration",{"$duration":30}],["text",{"$text":"literal@example.com"}],["$item","plain"]]}},"generators":[]});
let (draft, _) = portable::import(ws, &doc, &base, &value.to_string()).unwrap();
let out = portable::export(&doc, &draft).unwrap();
assert_eq!(out["variables"]["typed"], value["variables"]["typed"]);
let mut compact = portable::export(&doc, &base).unwrap();
let (first, _) = portable::import(ws, &doc, &base, &compact.to_string()).unwrap();
compact["generators"][0]["outputs"]
.as_array_mut()
.unwrap()
.reverse();
let (reordered, _) = portable::import(ws, &doc, &first, &compact.to_string()).unwrap();
let mut a: Vec<_> = first.rules[0].blocks.iter().map(|b| b.id).collect();
let mut b: Vec<_> = reordered.rules[0].blocks.iter().map(|b| b.id).collect();
a.sort();
b.sort();
assert_eq!(a, b);
}
#[test]
fn compact_named_list_reordering_and_field_edit_preserve_entry_ids() {
let (ws, doc, base) = fixture();
let mut v = json!({"format":"meguri/v2","variables":{"projects":[{"name":"a","people":["alice@example.com"]},{"name":"b","people":["bob@example.com"]}]},"generators":[]});
let (first, _) = portable::import(ws, &doc, &base, &v.to_string()).unwrap();
let old = first
.variables
.iter()
.find(|v| v.name == "projects")
.unwrap();
v["variables"]["projects"].as_array_mut().unwrap().reverse();
v["variables"]["projects"][0]["people"] = json!(["carol@example.com"]);
let (second, _) = portable::import(ws, &doc, &first, &v.to_string()).unwrap();
let new = second
.variables
.iter()
.find(|v| v.name == "projects")
.unwrap();
assert_eq!(old.node.entries[0].id, new.node.entries[1].id);
assert_eq!(old.node.entries[1].id, new.node.entries[0].id);
}
#[test]
fn compact_cross_workspace_and_complex_bindings_keep_effective_configuration() {
let (ws, doc, mut draft) = fixture();
for create in [demo::regional_platform, demo::service_tiers] {
(draft.variables, draft.rules) = create(
"2026-09-28".parse().unwrap(),
doc.people[0].id,
doc.people[1].id,
);
let value = portable::export(&doc, &draft).unwrap();
let text = portable::format_config(&value);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&text).unwrap(),
value
);
let (same, _) = portable::import(ws, &doc, &draft, &text).unwrap();
let now = chrono::Utc::now();
let expand = |d: &Draft, workspace| {
generation::expand(
&doc,
workspace,
&d.variables,
&d.rules,
0,
&doc.working_interval,
&Default::default(),
now,
)
};
let before = expand(&draft, ws);
let after = expand(&same, ws);
assert!(after.issues.is_empty(), "{:?}", after.issues);
assert_eq!(
json!(before.document.schedules),
json!(after.document.schedules)
);
assert_eq!(
json!(before.document.escalations),
json!(after.document.escalations)
);
assert_eq!(json!(before.document.routes), json!(after.document.routes));
let target = Uuid::new_v4();
let empty = Draft {
variables: vec![],
rules: vec![],
..draft.clone()
};
let (first, _) = portable::import(target, &doc, &empty, &text).unwrap();
let (again, _) = portable::import(target, &doc, &first, &text).unwrap();
assert_eq!(json!(first), json!(again));
assert!(expand(&first, target).issues.is_empty());
}
}
#[test]
fn guide_example_compiles_to_all_four_output_types() {
let catalog: serde_json::Value =
serde_json::from_str(include_str!("../static/locales/messages.json")).unwrap();
let (ws, doc, mut base) = fixture();
base.variables.clear();
base.rules.clear();
for language in ["en", "ru", "fr"] {
let source = catalog["transfer_guide_example"][language]
.as_str()
.unwrap();
let (draft, _) = portable::import(ws, &doc, &base, source).unwrap();
let expanded = generation::expand(
&doc,
ws,
&draft.variables,
&draft.rules,
0,
&doc.working_interval,
&Default::default(),
chrono::Utc::now(),
);
assert!(
expanded.issues.is_empty(),
"{language}: {:?}",
expanded.issues
);
assert_eq!(expanded.document.schedules.len(), 2);
assert_eq!(expanded.document.escalations.len(), 1);
assert_eq!(expanded.document.routes.len(), 1);
assert!(matches!(
expanded.document.escalations[0].steps[1].recipient,
Recipient::TeamAll
));
assert_eq!(expanded.document.escalations[0].steps[1].delay_minutes, 30);
}
}
#[test]
fn random_team_import_roundtrips_and_rejects_unknown_modes() {
let (ws, doc, mut base) = fixture();
base.variables.clear();
base.rules.clear();
let mut source = json!({"format":"meguri/v2","variables":{},"generators":[{"name":"Random","outputs":[{"key":"policy","type":"escalation","name":"Random","steps":[{"after":30,"team":"random"}]}]}]});
let (draft, _) = portable::import(ws, &doc, &base, &source.to_string()).unwrap();
let out = generation::expand(
&doc,
ws,
&draft.variables,
&draft.rules,
0,
&doc.working_interval,
&Default::default(),
chrono::Utc::now(),
);
assert!(out.issues.is_empty(), "{:?}", out.issues);
assert!(matches!(
out.document.escalations[0].steps[0].recipient,
Recipient::TeamRandom
));
assert_eq!(out.document.escalations[0].steps[0].delay_minutes, 30);
for export in [
portable::export(&doc, &draft).unwrap(),
portable::export_v1(&doc, &draft).unwrap(),
] {
let (roundtrip, _) = portable::import(ws, &doc, &draft, &export.to_string()).unwrap();
assert!(roundtrip.rules[0].blocks[0].steps[0].team_random);
assert!(!roundtrip.rules[0].blocks[0].steps[0].team_all);
}
source["generators"][0]["outputs"][0]["steps"][0]["team"] = json!("sequential");
assert!(portable::import(ws, &doc, &base, &source.to_string()).is_err());
}
+180 -20
View File
@@ -13,10 +13,17 @@ use tokio::{
pub struct State {
pub objects: BTreeMap<String, Value>,
pub writes: usize,
pub rotation_creates: usize,
pub rotation_patches: usize,
pub fail_after_rotation_patch: bool,
pub fail_after_schedule_patch: bool,
pub fail_once: Option<String>,
pub external_override: bool,
pub users: Vec<Value>,
pub lookups: usize,
pub fail_after_override_create: bool,
pub fail_after_resource_create: bool,
pub override_not_before: Option<chrono::DateTime<chrono::Utc>>,
}
pub struct Server {
pub base: String,
@@ -72,13 +79,14 @@ impl Server {
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 uri = first.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 (code, result) =
handle_page(&mut shared.lock().unwrap(), method, uri, 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}",
@@ -91,6 +99,42 @@ impl Server {
Self { base, state, task }
}
}
fn handle_page(s: &mut State, method: &str, uri: &str, body: Value) -> (u16, Value) {
let url = reqwest::Url::parse(&format!("http://localhost{uri}")).unwrap();
let query: BTreeMap<_, _> = url.query_pairs().into_owned().collect();
let size = query.get("size").and_then(|s| s.parse::<usize>().ok());
if size.is_some_and(|size| size > 50) {
return (
422,
json!({"errors":[{"title":"Field: size, error: must be less than or equal to 50"}]}),
);
}
let (code, mut result) = handle(s, method, url.path(), body);
if method == "GET"
&& let Some(size) = size
&& let Some(values) = result.get_mut("values").and_then(Value::as_array_mut)
{
let offset = query
.get("offset")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let more = offset + size < values.len();
*values = values.iter().skip(offset).take(size).cloned().collect();
if more {
result["links"] =
json!({"next":format!("{}?offset={}&size={size}",url.path(),offset+size)});
}
}
(code, result)
}
fn read_response(path: &str, value: &Value) -> Value {
let mut response = value.clone();
if path.contains("/escalations/") && response["repeat"]["waitInterval"] == 0 {
response.as_object_mut().unwrap().remove("repeat");
}
response
}
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;
@@ -102,19 +146,36 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
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 !s
.objects
.contains_key(path.strip_suffix("/overrides").unwrap())
{
return (404, json!({}));
}
let mut values: Vec<_> = s
.objects
.iter()
.filter(|(key, _)| {
key.rsplit_once('/')
.is_some_and(|(parent, _)| parent == path)
})
.map(|(_, value)| value.clone())
.collect();
if s.external_override {
values.push(json!({"alias":"external"}));
}
return (200, json!({"values":values}));
}
if let Some(parent) = path.strip_suffix("/rotations") {
return (
200,
json!({"values":s.objects.get(parent).unwrap()["rotations"]}),
);
let Some(object) = s.objects.get(parent) else {
return (404, json!({}));
};
let mut rotations = object["rotations"].as_array().unwrap().clone();
rotations.reverse();
return (200, json!({"values":rotations}));
}
if let Some(value) = s.objects.get(path) {
return (200, value.clone());
return (200, read_response(path, value));
}
if path.ends_with("/schedules")
|| path.ends_with("/escalations")
@@ -122,7 +183,7 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
{
return (
200,
json!({"values":s.objects.iter().filter(|(k,_)|k.rsplit_once('/').is_some_and(|(p,_)|p==path)).map(|(_,v)|v.clone()).collect::<Vec<_>>()}),
json!({"values":s.objects.iter().filter(|(k,_)|k.rsplit_once('/').is_some_and(|(p,_)|p==path)).map(|(k,v)|read_response(k,v)).collect::<Vec<_>>()}),
);
}
return (404, json!({}));
@@ -133,6 +194,8 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
s.external_override = false;
} else {
s.objects.remove(path);
s.objects
.retain(|key, _| !key.starts_with(&format!("{path}/")));
}
return (200, json!({"result":"deleted"}));
}
@@ -144,26 +207,123 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
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()
if path.contains("/overrides") {
assert!(body["responder"]["type"] == "user" || body["responder"]["type"] == "noone");
for field in ["startDate", "endDate"] {
let time = body[field]
.as_str()
.unwrap()
.parse::<chrono::DateTime<chrono::Utc>>()
.unwrap();
body[field] =
json!(chrono::DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap());
}
if method == "POST"
&& s.override_not_before.is_some_and(|now| {
body["startDate"]
.as_str()
.unwrap()
.parse::<chrono::DateTime<chrono::Utc>>()
.unwrap()
< now
})
{
return (
400,
json!({"error":"Start time cannot be earlier than the current time"}),
);
}
s.writes += 1;
if method == "POST" {
let alias = uuid::Uuid::new_v4().to_string();
body["alias"] = json!(alias);
s.objects.insert(format!("{path}/{alias}"), body.clone());
if s.fail_after_override_create {
s.fail_after_override_create = false;
return (500, json!({"error":"Lost response after create"}));
}
} else {
assert_eq!(method, "PUT");
body["alias"] = json!(path.rsplit('/').next().unwrap());
s.objects.insert(path.into(), body.clone());
}
return (201, body);
}
if let Some((parent, id)) = path.split_once("/rotations/") {
assert_eq!(method, "PATCH");
let Some(rotation) = s
.objects
.get_mut(parent)
.and_then(|v| v["rotations"].as_array_mut())
.and_then(|r| r.iter_mut().find(|r| r["id"] == id))
else {
return (404, json!({}));
};
body["id"] = json!(id);
*rotation = body.clone();
s.writes += 1;
s.rotation_patches += 1;
if s.fail_after_rotation_patch {
s.fail_after_rotation_patch = false;
return (500, json!({"error":"Lost rotation update response"}));
}
return (200, body);
}
if path.contains("schedules") && body.get("rotations").is_some() {
let zone = body["timezone"]
.as_str()
.unwrap()
.parse::<chrono_tz::Tz>()
.unwrap();
for rotation in body["rotations"].as_array_mut().unwrap() {
rotation["id"] = json!(uuid::Uuid::new_v4().to_string());
s.rotation_creates += 1;
assert!(!rotation["participants"].as_array().unwrap().is_empty());
if let Some(end) = rotation["endDate"].as_str() {
assert!(end > rotation["startDate"].as_str().unwrap());
} else {
assert_eq!(rotation["participants"][0]["type"], "noone");
}
// Anonymized live behavior: native rotation minutes snap to a half-hour;
// override endpoints above retain arbitrary minutes and milliseconds.
for field in ["startDate", "endDate"] {
if let Some(raw) = rotation[field].as_str() {
use chrono::Timelike;
let time = raw.parse::<chrono::DateTime<chrono::Utc>>().unwrap();
let minute = i64::from(time.with_timezone(&zone).minute());
let rounded =
time + chrono::Duration::minutes(((minute + 15) / 30) * 30 - minute);
rotation[field] = json!(
chrono::DateTime::from_timestamp_millis(rounded.timestamp_millis())
.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());
if s.fail_after_resource_create {
s.fail_after_resource_create = false;
return (500, json!({"error":"Lost resource create response"}));
}
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)
let mut merged = previous.clone();
for (key, value) in body.as_object().unwrap() {
merged[key] = value.clone();
}
s.objects.insert(path.into(), merged.clone());
if path.starts_with("/schedules/") && s.fail_after_schedule_patch {
s.fail_after_schedule_patch = false;
return (500, json!({"error":"Lost schedule metadata response"}));
}
(200, merged)
} else {
(404, json!({}))
}
+36 -6
View File
@@ -1,8 +1,8 @@
use chrono::{Duration, Utc};
use cot::db::Model;
use cot::db::{Model, query};
use meguri::{
domain::*,
models::UserRow,
models::{HistoryRow, UserRow},
service,
store::{Store, WorkspaceRecord},
};
@@ -24,19 +24,26 @@ impl Fixture {
for name in ["Alice", "Bob", "Carol"] {
let user = User {
language: None,
id: Uuid::new_v4(),
person: Person {
id: Uuid::new_v4(),
user_id: None,
display_name: name.into(),
email: Some(format!("{}@example.com", Uuid::new_v4())),
country: Some("CY".into()),
},
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
country: "CY".into(),
timezone: None,
};
let mut user = user;
user.person.user_id = Some(user.id);
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(),
country: user.country.clone().unwrap(),
email: user.email.clone().unwrap(),
timezone: None,
password_hash: "test-only-unusable-password".into(),
created_at: Utc::now(),
@@ -121,6 +128,29 @@ impl Fixture {
}
}
pub async fn prioritize_publication(&self) {
let revision = self.db.workspace(self.team.id).await.unwrap().revision;
self.db
.retry(self.team.id, self.people[0].id, revision)
.await
.unwrap();
// PostgreSQL and the worker can run on different host clocks. Publish only
// once this test's latest committed revision has actually become effective.
let workspace = self.team.id.to_string();
let effective = query!(HistoryRow, $workspace_id == workspace)
.all(&self.db.db)
.await
.unwrap()
.into_iter()
.map(|row| row.effective_at)
.max()
.unwrap();
if let Ok(delay) = (effective - Utc::now()).to_std() {
assert!(
delay < std::time::Duration::from_secs(2),
"Database clock is too far ahead of the test worker"
);
tokio::time::sleep(delay + std::time::Duration::from_millis(1)).await;
}
// 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