Files
meguri/tests/browser-portable-config.cjs
Aleksandr BogomiakovandClaude 38105c65c2 Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
NEXT predates the access work, so the identity layer follows master: access comes from
configured groups, instance administrators replace the site administration, and accounts are
created by SSO or by an administrator. Kept from NEXT: manual publication with the Review
changes panel and shared generator drafts, portable import/export (meguri/v2), the layered
generation map with binding provenance, JSM provider changes, contact/approval semantics and
the account-owned Person. Dropped: site administration, local sign-up, and the mandatory
unique email with its contact/account migrations. The manual-publication migration is
renumbered m_0010 after m_0009_access.

Accounts without an email still get a Person in every workspace they reach, a changed sign-in
email updates the account's person, and an account that loses access keeps its linked person so
schedules stay valid. Instance administrators may use Sync and shared drafts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 16:06:10 +01:00

69 lines
7.2 KiB
JavaScript

const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser,isolatedUser,signIn}=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));
// Sandbox 10 belongs to this suite; workspaces cannot be created, so it starts empty.
const tester=await isolatedUser({sandbox:10,prefix:'config-transfer',display:'Path tester'});
await signIn(page,tester.username,tester.password);
await page.getByLabel('Workspace',{exact:true}).waitFor();
const dialog=page.getByRole('dialog');
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;});