1 parent
e37d583179
commit
5260c2828b
65 files changed
+8469
-380
No files matched your search
@@ -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()=>{
|
||||
|
||||
@@ -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;});
|
||||
@@ -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;});
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;});
|
||||
@@ -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;});
|
||||
@@ -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;});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);});
|
||||
@@ -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;});
|
||||
@@ -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;});
|
||||
Vendored
+437
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user