2026-10-07 15:30:31 +01:00
const assert = require ( 'node:assert/strict' );
const fs = require ( 'node:fs' );
2026-10-07 16:06:10 +01:00
const { launchBrowser , isolatedUser , signIn } = require ( './support/browser.cjs' );
2026-10-07 15:30:31 +01:00
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 ));
2026-10-07 16:06:10 +01:00
// 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 ();
2026-10-07 15:30:31 +01:00
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 ;});