2026-10-10 08:36:23 +08:00
/* Full-page and modal layout audit in an isolated Mock service only. */
const assert = require ( 'assert' ); const fs = require ( 'fs' ); const { chromium } = require ( 'playwright' );
( async ()=>{
const base = process . env . FRAME_FLOW_URL ; assert ( base ); const token = fs . readFileSync ( process . env . FRAME_FLOW_ENV_FILE , 'utf8' ). split ( '\n' ). find ( x => x . startsWith ( 'FRAME_FLOW_TOKEN=' )). slice ( 17 );
assert (( await ( await fetch ( base + '/api/v1/settings' ,{ headers : { Authorization : 'Bearer ' + token }})). json ()). mock === true );
const browser = await chromium . launch ({ headless : true , executablePath : process . env . CHROME_EXECUTABLE || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' });
try {
const page = await browser . newPage (); const errors = []; page . on ( 'pageerror' , e => errors . push ( e . message )); let connected = false ; let logins = 0 ;
await page . route ( '**/api/v1/integrations/synology-photos' , async route =>{ if ( route . request (). method () === 'POST' ){ assert . deepStrictEqual ( route . request (). postDataJSON (),{ username : 'fixture-nas' , password : 'fixture-only' }); connected = true ; logins ++ ;} await route . fulfill ({ json : { configured : true , connected }});});
await page . route ( '**/api/v1/photo-sources' , async route =>{ assert ( route . request (). method () === 'GET' ); await route . fulfill ({ json : [{ id : 'fixture-source' , name : 'NAS photos / NAS 照片' , kind : 'local' , location : '.' , enabled : false , people_filter : true , orientation : 'auto' }]});});
await page . goto ( base ); await page . locator ( '#username' ). fill ( process . env . FRAME_FLOW_TEST_USERNAME ); await page . locator ( '#password' ). fill ( process . env . FRAME_FLOW_TEST_PASSWORD ); await page . locator ( '#login-form button' ). click (); await page . locator ( '.hero' ). waitFor ();
2026-10-16 16:25:42 +08:00
const viewports = process . env . FRAME_FLOW_VIEWPORTS ? JSON . parse ( process . env . FRAME_FLOW_VIEWPORTS ) : [ 1440 , 768 , 390 ]. map ( width =>({ width , height : 900 }));
2026-10-10 08:36:23 +08:00
let checks = 0 ; const failures = []; const out = process . env . FRAME_FLOW_SCREENSHOT_DIR ; if ( out ) fs . mkdirSync ( out ,{ recursive : true });
async function inspect ( label ){
const result = await page . evaluate (()=>{
const root = document . querySelector ( 'dialog[open]' ) || document . querySelector ( '#main' ); const controls = [... root . querySelectorAll ( 'button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea' )]. filter ( e => e . checkVisibility ({ checkOpacity : true , checkVisibilityCSS : true }) &&! e . closest ( '[hidden]' )). map ( e =>({ id : e . id || e . textContent . trim (). slice ( 0 , 24 ), r : e . getBoundingClientRect ()})); const close = [];
for ( let i = 0 ; i < controls . length ; i ++ ) for ( let j = i + 1 ; j < controls . length ; j ++ ){ const a = controls [ i ]. r , b = controls [ j ]. r ; const x = Math . min ( a . right , b . right ) - Math . max ( a . left , b . left ), y = Math . min ( a . bottom , b . bottom ) - Math . max ( a . top , b . top ); const dx = Math . max ( a . left , b . left ) - Math . min ( a . right , b . right ), dy = Math . max ( a . top , b . top ) - Math . min ( a . bottom , b . bottom ); if (( x > 1 && dy < 7.5 ) || ( y > 1 && dx < 7.5 )) close . push ([ controls [ i ]. id , controls [ j ]. id ]);}
2026-10-16 16:25:42 +08:00
const dialog = document . querySelector ( 'dialog[open]' ); const rect = dialog ? . getBoundingClientRect ();
const clippedDialog =!! rect && ( rect . left < 0 || rect . right > innerWidth || rect . top < 0 || rect . bottom > innerHeight );
const clippedNav = innerWidth <= 720 && [... document . querySelectorAll ( 'nav button' )]. some ( button =>{ const r = button . getBoundingClientRect (); return button . scrollHeight > button . clientHeight || r . height < 44 ;});
return { clippedDialog , clippedNav , overflow : document . documentElement . scrollWidth > innerWidth , close , small : innerWidth <= 720 ? controls . filter ( e => e . r . height < 43.5 ). map ( e => e . id ) : []};
}); checks ++ ; if ( result . clippedDialog || result . clippedNav || result . overflow || result . close . length || result . small . length ) failures . push ({ label ,... result });
2026-10-10 08:36:23 +08:00
}
2026-10-16 16:25:42 +08:00
for ( const { width , height } of viewports ) for ( const lang of [ 'zh' , 'en' ]) for ( const theme of [ 'light' , 'dark' ]){
await page . setViewportSize ({ width , height }); await page . evaluate (({ lang , theme })=>{ state . lang = lang ; document . documentElement . dataset . theme = theme ; shell ();},{ lang , theme });
2026-10-10 08:36:23 +08:00
for ( const view of [ 'dashboard' , 'gallery' , 'photo-sources' , 'upload' , 'templates' , 'automation' , 'playlists' , 'history' , 'settings' , 'studio' , 'connections' ]){ await page . evaluate ( view => navigate ( view ), view ); await inspect ( ` ${ view } / ${ width } / ${ lang } / ${ theme } ` );}
2026-10-10 09:28:37 +08:00
await page . evaluate (()=> navigate ( 'studio' )); await page . locator ( '.manual-collage summary' ). click (); await inspect ( `Manual collage/ ${ width } / ${ lang } / ${ theme } ` ); await page . evaluate (()=> navigate ( 'photo-sources' )); await page . locator ( '#photos-login' ). click (); await inspect ( `Photos login/ ${ width } / ${ lang } / ${ theme } ` ); await page . locator ( '#modal-cancel' ). click ();
2026-10-10 08:36:23 +08:00
await page . locator ( '#add-photo-source' ). click (); assert ( await page . locator ( '#source-credential' ). isHidden ()); assert ( await page . locator ( '#source-oauth' ). isHidden ()); await inspect ( `NAS source/ ${ width } / ${ lang } / ${ theme } ` ); await page . locator ( '#source-kind' ). selectOption ( 'google-drive' ); assert ( await page . locator ( '#source-oauth' ). isVisible ()); assert ( await page . locator ( '#source-local-quality' ). isHidden ()); await inspect ( `Drive source/ ${ width } / ${ lang } / ${ theme } ` ); await page . locator ( '#modal-cancel' ). click ();
await page . evaluate (()=> navigate ( 'automation' )); await page . locator ( '#new-job' ). click (); await inspect ( `Job/ ${ width } / ${ lang } / ${ theme } ` ); await page . locator ( '#modal-cancel' ). click ();
await page . evaluate (()=> navigate ( 'playlists' )); await page . locator ( '#new-playlist' ). click (); await inspect ( `Playlist/ ${ width } / ${ lang } / ${ theme } ` ); await page . locator ( '#modal-cancel' ). click ();
await page . evaluate (()=> displayDialog ({ type : 'image' , image_id : 1 })); await inspect ( `Display options/ ${ width } / ${ lang } / ${ theme } ` ); await page . locator ( '#modal-cancel' ). click ();
await page . evaluate (()=>{ state . tab = 'local' ; return navigate ( 'gallery' );}); await inspect ( `Local gallery/ ${ width } / ${ lang } / ${ theme } ` ); await page . evaluate (()=>{ state . tab = 'cloud' ;});
if ( out && lang === 'zh' && theme === 'dark' ){ await page . evaluate (()=> navigate ( 'photo-sources' )); await page . screenshot ({ path : out + `/photos- ${ width } .png` , fullPage : true });}
}
await page . evaluate (()=> navigate ( 'photo-sources' )); await page . locator ( '#photos-login' ). click (); await page . locator ( '#photos-login-user' ). fill ( 'fixture-nas' ); await page . locator ( '#photos-login-password' ). fill ( 'fixture-only' ); await page . locator ( '#photos-login-form button[type=submit]' ). click (); await page . locator ( '#photos-login' ). filter ({ hasText : 'Sign in again' }). waitFor (); assert ( logins === 1 ); checks ++ ;
2026-10-16 16:25:42 +08:00
assert . deepStrictEqual ( errors ,[]); assert . deepStrictEqual ( failures ,[], JSON . stringify ( failures )); console . log ( 'Layout audit:' , checks , 'passed; 11 pages plus local gallery, 6 modal states, ' + ( viewports . length * 4 ) + ' viewport/language/theme combinations; console errors:' , errors . length );
2026-10-10 08:36:23 +08:00
} finally { await browser . close ();}
})(). catch ( e =>{ console . error ( e . message ); process . exit ( 1 )});