2026-10-02 00:03:48 +08:00
/* Headless test of this app only; never attaches to the user's browser session. */
const fs = require ( 'fs' ); const path = require ( 'path' ); const assert = require ( 'assert' ); const { chromium } = require ( 'playwright' );
( async ()=>{
2026-10-02 01:52:38 +08:00
const uploadName = 'browser-upload-' + Date . now (); const root = path . resolve ( __dirname , '..' ); const env = fs . readFileSync ( process . env . FRAME_FLOW_ENV_FILE || path . join ( root , '.env' ), 'utf8' ); const token = env . split ( '\n' ). find ( x => x . startsWith ( 'FRAME_FLOW_TOKEN=' )). slice ( 'FRAME_FLOW_TOKEN=' . length );
2026-10-16 16:25:42 +08:00
const base = process . env . FRAME_FLOW_URL || 'http://127.0.0.1:6005' ; const response = await fetch ( base + '/api/v1/settings' ,{ headers : { Authorization : 'Bearer ' + token }}); const settings = await response . json (); assert ( settings . mock === true , 'UI smoke requires Mock mode' );
2026-10-02 00:03:48 +08:00
const browser = await chromium . launch ({ headless : true , executablePath : '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' });
2026-10-02 02:23:24 +08:00
const context = await browser . newContext ({ viewport : { width : 1440 , height : 1000 }}); const page = await context . newPage (); const errors = []; page . on ( 'pageerror' , e => errors . push ( e . message . replaceAll ( token , '[REDACTED]' ))); let checks = 0 ;
2026-10-02 08:43:07 +08:00
await page . addInitScript (()=> Object . defineProperty ( window . crypto , 'randomUUID' ,{ value : undefined , configurable : true })); 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 (); assert ( await page . locator ( 'h2' ). first (). innerText () === '客厅相框' ); checks ++ ;
2026-10-02 00:49:07 +08:00
const shots = process . env . FRAME_FLOW_SCREENSHOT_DIR || path . join ( root , 'docs/screenshots' ); fs . mkdirSync ( shots ,{ recursive : true }); await page . screenshot ({ path : path . join ( shots , 'dashboard-light-zh.png' ), fullPage : true });
2026-10-02 00:03:48 +08:00
await page . locator ( '#lang' ). click (); await page . getByRole ( 'heading' ,{ name : 'Dashboard' , exact : true }). waitFor (); checks ++ ;
await page . locator ( '#theme' ). click (); assert ( await page . locator ( 'html' ). getAttribute ( 'data-theme' ) === 'dark' ); checks ++ ;
2026-10-02 00:49:07 +08:00
await page . screenshot ({ path : path . join ( shots , 'dashboard-dark-en.png' ), fullPage : true });
2026-10-23 22:57:53 +08:00
await page . locator ( '[data-page="gallery"]' ). click (); await page . locator ( '#library-content' ). waitFor (); assert . equal ( await page . locator ( '#library-legacy' ). count (), 0 ); checks ++ ;
await page . locator ( '[data-page="terminal-gallery"]' ). click (); await page . locator ( '[data-library-terminal="watch"]' ). click (); await page . locator ( '[data-library-terminal="watch"][aria-pressed=true]' ). waitFor (); checks ++ ;
await page . locator ( '[data-library-terminal="living-room"]' ). click (); await page . locator ( '#library-binding' ). click (); await page . locator ( '#terminal-binding-form' ). waitFor (); await page . locator ( '#modal-cancel' ). click (); checks ++ ;
2026-10-02 01:52:38 +08:00
await page . locator ( '[data-page="upload"]' ). click (); await page . locator ( '#file' ). setInputFiles ({ name : uploadName + '.png' , mimeType : 'image/png' , buffer : Buffer . from ( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=' , 'base64' )}); checks ++ ;
2026-10-02 00:11:37 +08:00
await page . locator ( '#make-preview' ). click (); await page . waitForFunction (()=> ! document . querySelector ( '#save-local' ). disabled ); checks ++ ;
2026-10-23 22:57:53 +08:00
await page . locator ( '#save-local' ). click (); await page . waitForFunction ( name => document . querySelector ( '#toast' ) ? . textContent . includes ( 'Saved on server' ), uploadName ); checks ++ ;
const uploadedId = await page . evaluate ( async name =>( await api ( '/api/v1/assets?source=uploaded&limit=200' )). find ( x => x . name === name + '.jpg' ) ? . asset_id , uploadName ); assert ( uploadedId ); await page . evaluate ( id => api ( '/api/v1/assets/' + id ,{ method : 'DELETE' }), uploadedId ); checks ++ ;
2026-10-10 01:25:32 +08:00
await page . locator ( '[data-page="templates"]' ). click (); await page . locator ( '.template-card' ). first (). waitFor (); assert ( await page . locator ( '.template-card' ). count () === 9 ); checks ++ ;
2026-10-02 00:03:48 +08:00
await page . locator ( '#render-template' ). click (); await page . locator ( '#template-image' ). waitFor ({ timeout : 30000 }); assert ( await page . locator ( '#display-template' ). isEnabled ()); checks ++ ;
2026-10-10 01:28:27 +08:00
await page . locator ( '#field-title' ). fill ( 'Edited title' ); assert ( await page . locator ( '#display-template' ). isDisabled ()); checks ++ ; await page . locator ( '#save-template' ). click (); await page . reload (); await page . locator ( '#field-title' ). waitFor (); assert ( await page . locator ( '#field-title' ). inputValue () === 'Edited title' ); checks ++ ; await page . locator ( '#reset-template' ). click (); await page . locator ( '#modal-confirm' ). click (); await page . waitForFunction (()=> document . querySelector ( '#field-title' ) ? . value !== 'Edited title' && document . querySelector ( '#field-title' )); assert ( await page . locator ( '#field-title' ). inputValue () !== 'Edited title' ); checks ++ ; await page . locator ( '#field-title' ). fill ( 'Provenance UI title' ); await page . locator ( '#render-template' ). click (); await page . locator ( '#template-image' ). waitFor (); await page . locator ( '#display-template' ). click (); await page . locator ( '#confirm-display' ). click (); await page . locator ( '[data-page="history"]' ). click (); await page . locator ( 'tbody tr' ). first (). waitFor (); checks ++ ; await page . locator ( 'tbody tr button' ). first (). click (); await page . locator ( '#modal-cancel' ). click (); checks ++ ; await page . waitForFunction ( async ()=>{ const response = await fetch ( '/api/v1/devices/living-room/current-content' ); const content = await response . json (); return content . source ? . data ? . title === 'Provenance UI title' ;}); await page . locator ( '[data-page="dashboard"]' ). click (); await page . locator ( '#edit-current-template' ). waitFor ({ timeout : 30000 }); await page . locator ( '#edit-current-template' ). click (); await page . locator ( '#field-title' ). waitFor (); assert ( await page . locator ( '#field-title' ). inputValue () === 'Provenance UI title' ); checks ++ ;
2026-10-02 09:19:25 +08:00
await page . locator ( '[data-page="automation"]' ). click (); await page . locator ( '#new-job' ). click (); await page . locator ( '#job-content-type' ). waitFor (); await page . locator ( '#job-template' ). selectOption ( 'quote' ); checks ++ ; await page . locator ( '#job-name' ). fill ( 'UI acceptance' ); await page . locator ( '#job-form button[type=submit]' ). click (); await page . locator ( 'td strong' ). filter ({ hasText : 'UI acceptance' }). waitFor (); checks ++ ;
2026-10-02 00:03:48 +08:00
await page . locator ( '[data-job-toggle]' ). first (). click (); await page . getByText ( 'Paused' ,{ exact : true }). first (). waitFor (); checks ++ ;
await page . locator ( '[data-job-delete]' ). first (). click (); await page . locator ( '#modal-confirm' ). click (); await page . locator ( 'td strong' ). filter ({ hasText : 'UI acceptance' }). waitFor ({ state : 'hidden' }); checks ++ ;
await page . locator ( '[data-page="playlists"]' ). click (); await page . locator ( '#new-playlist' ). click (); await page . locator ( '#playlist-name' ). fill ( 'UI art' ); await page . locator ( '#playlist-images' ). fill ( '1' ); await page . locator ( '#playlist-form button[type=submit]' ). click (); await page . getByText ( 'UI art' ,{ exact : true }). waitFor (); checks ++ ;
await page . locator ( '[data-pl-delete]' ). click (); await page . locator ( '#modal-confirm' ). click (); checks ++ ;
2026-10-02 09:19:25 +08:00
await page . locator ( '[data-page="settings"]' ). click (); await page . locator ( '#theme-select' ). waitFor (); await page . locator ( '#weather-form' ). waitFor (); assert ( await page . locator ( '#weather-provider' ). inputValue () === 'mock' ); checks ++ ; await page . locator ( '#theme-select' ). selectOption ( 'light' ); assert ( await page . locator ( 'html' ). getAttribute ( 'data-theme' ) === 'light' ); checks ++ ;
2026-10-02 00:03:48 +08:00
await page . locator ( '#language-select' ). selectOption ( 'zh' ); await page . getByRole ( 'heading' ,{ name : '设置' , exact : true }). waitFor (); checks ++ ;
await page . setViewportSize ({ width : 390 , height : 844 }); await page . locator ( '[data-page="dashboard"]' ). click (); await page . locator ( '.hero' ). waitFor (); assert ( await page . evaluate (()=> document . documentElement . scrollWidth <= window . innerWidth )); checks ++ ;
2026-10-02 00:49:07 +08:00
await page . screenshot ({ path : path . join ( shots , 'dashboard-mobile-zh.png' ), fullPage : true });
2026-10-02 02:23:24 +08:00
await page . reload (); await page . locator ( '.hero' ). waitFor (); checks ++ ;
const reopened = await page . context (). newPage (); await reopened . goto ( base ); await reopened . locator ( '.hero' ). waitFor (); await reopened . close (); checks ++ ;
await page . locator ( '[data-page="settings"]' ). click (); await page . locator ( '#logout' ). click (); await page . locator ( '#login-form' ). waitFor (); assert ( await page . evaluate (()=> localStorage . getItem ( 'ff-login' ) === null )); checks ++ ;
2026-10-02 08:43:07 +08:00
await page . locator ( '#remember-login' ). uncheck (); 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 ( '#logout' ). waitFor (); await page . reload (); await page . locator ( '#logout' ). waitFor (); assert (( await context . cookies ()). find ( c => c . name === 'frameflow_session' ). expires ===- 1 ); checks ++ ;
assert ( await page . evaluate (()=> ! document . cookie . includes ( 'frameflow_session' ))); checks ++ ;
2026-10-02 00:03:48 +08:00
assert ( errors . length === 0 , JSON . stringify ( errors )); checks ++ ;
await browser . close (); console . log ( 'Browser smoke:' , checks , 'passed; page errors:' , errors . length );
})(). catch ( error =>{ console . error ( error . message ); process . exit ( 1 )});