2026-11-01 10:48:12 -08:00
#!/usr/bin/env node
/**
* Real-browser (Firefox AND Chromium, headless) end-to-end test of the Sitesmith modal.
*
* Builds the editor to a scratch dir, serves it from a mock server that implements the Sitesmith
* backend contract (scripts/e2e-sitesmith-mock-server.mjs), and drives the REAL modal: job
* start/poll/cancel/resume, mockup + reference upload, "Sites I like", asset awareness, contrast
* repair, vision-unavailable mode and the one-round self-review (real html-to-image capture).
* No real host or AI provider is contacted: every non-127.0.0.1 request is answered locally.
*
* npm run test:browser:sitesmith
*
* Env:
* CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries
* SITESMITH_E2E_DIST=<dir> where to build (default: a fresh temp dir)
* SITESMITH_E2E_SKIP_BUILD=1 reuse an existing build in SITESMITH_E2E_DIST
* SITESMITH_SHOTS=<dir> Firefox screenshots go here (default: <tmp>/sitesmith-shots)
* SITESMITH_ONLY=chromium|firefox run one browser
* SITESMITH_SCENARIO=3,5b run only these scenario ids
*/
import { chromium , firefox } from '@playwright/test' ;
import { spawnSync } from 'node:child_process' ;
import fs from 'node:fs' ;
import os from 'node:os' ;
import path from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
import { createMockServer , makePng } from './e2e-sitesmith-mock-server.mjs' ;
2026-11-01 20:40:09 -08:00
import { execFileSync } from 'node:child_process' ;
2026-11-01 10:48:12 -08:00
const root = path . resolve ( path . dirname ( fileURLToPath ( import . meta . url )), '..' );
const distDir = path . resolve ( process . env . SITESMITH_E2E_DIST || fs . mkdtempSync ( path . join ( os . tmpdir (), 'sitesmith-e2e-' )));
const shotsDir = path . resolve ( process . env . SITESMITH_SHOTS || path . join ( os . tmpdir (), 'sitesmith-shots' ));
fs . mkdirSync ( shotsDir , { recursive : true });
if ( ! ( process . env . SITESMITH_E2E_SKIP_BUILD && fs . existsSync ( path . join ( distDir , 'index.html' )))) {
console . log ( `building editor -> ${ distDir } ` );
const b = spawnSync ( 'npx' , [ 'vite' , 'build' , '--outDir' , distDir , '--emptyOutDir' ], { cwd : root , stdio : [ 'ignore' , 'pipe' , 'pipe' ], encoding : 'utf8' });
if ( b . status !== 0 ) { console . error ( b . stdout , b . stderr ); process . exit ( 2 ); }
}
const srv = await createMockServer ({ distDir });
// ------------------------------------------------------------------ result builders
const el = ( name , props , nodes = []) => ({ type : { resolvedName : name }, props , nodes });
const pageResult = ( headingText , { id = 'new-h' , color = '#ffffff' , bg = '#0f172a' , size = '36px' , weight = '700' } = {}) => ({
type : 'replace' , scope : 'page' , message : 'Built it.' ,
pages : [{ name : 'Home' , tree : el ( 'Container' , { node_id : 'new-root' , style : { minHeight : '300px' , backgroundColor : '#ffffff' } }, [
el ( 'Container' , { node_id : id + '-sec' , tag : 'section' , style : { backgroundColor : bg , padding : '40px' } }, [
el ( 'Heading' , { node_id : id , text : headingText , level : 'h1' , style : { color , fontSize : size , fontWeight : weight } }),
]),
]) }],
});
// ------------------------------------------------------------------ harness
let failures = 0 ;
const log = ( b , s , ok , label , detail ) => {
if ( ! ok ) failures ++ ;
console . log ( `[ ${ b } ] ${ ok ? 'PASS' : 'FAIL' } ${ s } : ${ label }${ ! ok && detail !== undefined ? ` -- ${ detail } ` : '' } ` );
};
const INIT = `(() => {
window.__phases = []; window.__bad = [];
const BAD = /NetworkError|Failed to fetch/;
const scan = () => {
const st = document.querySelector('[data-testid=ss-job-status]');
if (st) {
const ph = st.getAttribute('data-phase') + '|' + (st.querySelector('[data-testid=ss-phase-label]')?.textContent || '');
if (window.__phases[window.__phases.length - 1] !== ph) window.__phases.push(ph);
}
for (const d of document.querySelectorAll('[role=dialog]')) {
const t = d.textContent || '';
const m = BAD.exec(t);
if (m && window.__bad.length < 5) window.__bad.push(t.slice(Math.max(0, m.index - 40), m.index + 60));
}
};
new MutationObserver(scan).observe(document, { subtree: true, childList: true, characterData: true, attributes: true });
try { if (!window.__keepSelfReview) localStorage.setItem('sitesmith.selfReview', 'off'); } catch (e) {}
})();` ;
async function newPage ( browser , name , { selfReview = false } = {}) {
const ctx = await browser . newContext ({ viewport : { width : 1280 , height : 900 } });
const external = [];
await ctx . route ( /^https?:\/\/(?!127\.0\.0\.1)/ , ( route ) => {
const u = route . request (). url ();
external . push ( u );
if ( /\.css(\?|$)|fonts\.googleapis/ . test ( u )) return route . fulfill ({ status : 200 , contentType : 'text/css' , headers : { 'access-control-allow-origin' : '*' }, body : '/* offline */' });
return route . abort ();
});
await ctx . addInitScript ( selfReview ? `window.__keepSelfReview = true; ${ INIT } ` : INIT );
const page = await ctx . newPage ();
const errors = [];
const seenUrls = [];
page . on ( 'pageerror' , ( e ) => errors . push ( String ( e )));
page . on ( 'request' , ( r ) => seenUrls . push ( r . url ()));
page . __errors = errors ; page . __urls = seenUrls ; page . __external = external ; page . __ctx = ctx ;
await page . goto ( srv . url + '/' );
await page . waitForSelector ( '.canvas-device-frame [data-craft-container]' , { timeout : 20000 });
await page . waitForTimeout ( 500 );
return page ;
}
async function openModal ( page , { waitInput = true } = {}) {
await page . click ( 'button.sitesmith-btn' );
await page . waitForSelector ( '[role=dialog]' , { timeout : 10000 });
if ( ! waitInput ) return ;
await page . waitForSelector ( 'textarea.sitesmith-textarea:not([disabled])' , { timeout : 10000 });
}
const sendMsg = async ( page , text ) => { await page . fill ( 'textarea.sitesmith-textarea' , text ); await page . click ( '[role=dialog] button:has-text("→")' ); };
const canvasText = ( page ) => page . evaluate (() => document . querySelector ( '.canvas-device-frame' ) ? . innerText ?? '' );
const waitCanvas = ( page , text , timeout = 20000 ) =>
page . waitForFunction (( t ) => ( document . querySelector ( '.canvas-device-frame' ) ? . innerText ?? '' ). includes ( t ), text , { timeout });
const expandSection = async ( page , id ) => {
const btn = page . locator ( `[data-testid=ss-section- ${ id } ] > button` );
if (( await btn . getAttribute ( 'aria-expanded' )) !== 'true' ) await btn . click ();
};
const modalShot = async ( page , name , file ) => { if ( name === 'firefox' ) await page . screenshot ({ path : path . join ( shotsDir , file ) }); };
const contrastIn = ( page , text ) => page . evaluate (( t ) => {
const lum = ([ r , g , b ]) => { const f = ( c ) => { c /= 255 ; return c <= 0.03928 ? c / 12.92 : (( c + 0.055 ) / 1.055 ) ** 2.4 ; }; return 0.2126 * f ( r ) + 0.7152 * f ( g ) + 0.0722 * f ( b ); };
const rgba = ( s ) => { const m = s . match ( /[\d.]+/g ). map ( Number ); return { c : m . slice ( 0 , 3 ), a : m . length > 3 ? m [ 3 ] : 1 }; };
const h = [... document . querySelectorAll ( '.canvas-device-frame h1,.canvas-device-frame h2' )]. find (( e ) => e . textContent . includes ( t ));
if ( ! h ) return null ;
const fg = rgba ( getComputedStyle ( h ). color );
let bg = null ;
for ( let n = h ; n ; n = n . parentElement ) { const b = rgba ( getComputedStyle ( n ). backgroundColor ); if ( b . a > 0.99 ) { bg = b . c ; break ; } }
bg = bg || [ 255 , 255 , 255 ];
const [ a , b ] = [ lum ( fg . c ), lum ( bg )];
return { color : getComputedStyle ( h ). color , bg : `rgb( ${ bg . join ( ', ' ) } )` , ratio : ( Math . max ( a , b ) + 0.05 ) / ( Math . min ( a , b ) + 0.05 ) };
}, text );
// ------------------------------------------------------------------ scenarios
const scenarios = [];
const scenario = ( id , title , fn , opts ) => scenarios . push ({ id , title , fn , opts });
scenario ( '1' , 'start -> poll progress -> result applied' , async ({ page , name , ok }) => {
srv . setNextResult ( pageResult ( 'Brand new heading' ));
await openModal ( page );
await modalShot ( page , name , '01-idle.png' );
await sendMsg ( page , 'Build me a home page' );
await page . waitForSelector ( '[data-testid=ss-job-status][data-phase=generating]' , { timeout : 15000 });
await modalShot ( page , name , '02-progress.png' );
await waitCanvas ( page , 'Brand new heading' );
ok ( 'result applied to the canvas' , true );
const phases = await page . evaluate (() => window . __phases );
const names = phases . map (( p ) => p . split ( '|' )[ 0 ]);
ok ( 'successive phases seen' , [ 'queued' , 'preparing' , 'captioning' , 'generating' , 'finalizing' ]. filter (( p ) => names . includes ( p )). length >= 4 , JSON . stringify ( phases ));
ok ( 'phase labels are shown as text' , phases . some (( p ) => / Designing your page / . test ( p )) && phases . some (( p ) => / Preparing / . test ( p )), JSON . stringify ( phases ));
const body = srv . starts [ 0 ] ?? {};
ok ( 'start body has site_id' , body . site_id === 1 , JSON . stringify ( body . site_id ));
ok ( 'start body has design.primaryColor' , typeof body . design ? . primaryColor === 'string' , JSON . stringify ( body . design ));
ok ( 'exactly one start (self-review off)' , srv . starts . length === 1 , String ( srv . starts . length ));
});
scenario ( '2' , 'cancel a slow job' , async ({ page , name , ok }) => {
srv . setSlow ( true );
srv . setNextResult ( pageResult ( 'Should never appear' ));
await openModal ( page );
const before = await canvasText ( page );
await sendMsg ( page , 'Something slow' );
await page . waitForSelector ( '[data-testid=ss-cancel]' , { timeout : 10000 });
await page . waitForSelector ( '[data-testid=ss-job-status][data-phase=generating]' , { timeout : 10000 });
ok ( 'input replaced by status (no textarea while running)' , ( await page . locator ( 'textarea.sitesmith-textarea' ). count ()) === 0 );
await modalShot ( page , name , '09-running-disabled.png' );
await page . click ( '[data-testid=ss-cancel]' );
await page . waitForSelector ( '[data-testid=ss-note]' , { timeout : 15000 });
ok ( 'mock recorded the cancel' , srv . cancels . length === 1 && typeof srv . cancels [ 0 ]. job_id === 'string' , JSON . stringify ( srv . cancels ));
const note = await page . textContent ( '[data-testid=ss-note]' );
ok ( 'UI shows a Cancelled note' , /Cancelled/ . test ( note ?? '' ), note );
ok ( 'canvas unchanged' , ( await canvasText ( page )) === before && ! ( await canvasText ( page )). includes ( 'Should never appear' ));
});
scenario ( '3' , 'reload with an active job resumes it' , async ({ page , ok }) => {
srv . setActiveJob ({ phase : 'generating' , elapsed_s : 4 , result : { ok : true , response : pageResult ( 'Resumed heading' ) } });
await page . reload ();
await page . waitForSelector ( '.canvas-device-frame [data-craft-container]' , { timeout : 20000 });
await openModal ( page , { waitInput : false }); // a resumed job replaces the input box
await page . waitForSelector ( '[data-testid=ss-job-status]' , { timeout : 15000 });
const txt = await page . textContent ( '[data-testid=ss-job-status]' );
ok ( 'status shows the picked-up text' , /Picked up a request that was already running/ . test ( txt ?? '' ), txt );
await waitCanvas ( page , 'Resumed heading' );
ok ( 'result applied after completion' , true );
ok ( 'no new start was made' , srv . starts . length === 0 , String ( srv . starts . length ));
});
scenario ( '4' , 'mockup upload + client-side rejections' , async ({ page , name , ok }) => {
srv . setNextResult ( pageResult ( 'Mockup based heading' ));
await openModal ( page );
await expandSection ( page , 'mockups' );
const input = page . locator ( 'input[data-testid=ss-mockup-input]' );
await input . setInputFiles ({ name : 'mock.png' , mimeType : 'image/png' , buffer : makePng ( 64 , 48 ) });
await page . waitForSelector ( '[data-testid=ss-mockup-item] img' , { timeout : 10000 });
ok ( 'one upload reached the server' , srv . uploads . length === 1 && !! srv . uploads [ 0 ]. id , JSON . stringify ( srv . uploads ));
await page . waitForFunction (() => { const i = document . querySelector ( '[data-testid=ss-mockup-item] img' ); return i && i . complete && i . naturalWidth > 0 ; }, null , { timeout : 10000 });
ok ( 'thumbnail rendered from the server' , true );
await modalShot ( page , name , '03-mockup-attached.png' );
const alertText = () => page . locator ( '[data-testid=ss-mockups] [role=alert]' ). first (). textContent ();
await input . setInputFiles ({ name : 'x.png' , mimeType : 'image/png' , buffer : Buffer . from ( 'this is plain text, not a picture at all' ) });
await page . waitForSelector ( '[data-testid=ss-mockups] [role=alert]' , { timeout : 5000 });
const a1 = await alertText ();
ok ( 'non-image rejected client-side with a message' , !! a1 && a1 . length > 5 , a1 );
await page . waitForTimeout ( 400 );
ok ( 'non-image never reached the server' , srv . uploads . length === 1 , String ( srv . uploads . length ));
await input . setInputFiles ({ name : 'big.png' , mimeType : 'image/png' , buffer : Buffer . concat ([ makePng ( 8 , 8 ), Buffer . alloc ( 9 * 1024 * 1024 )]) });
await page . waitForFunction (( prev ) => { const a = document . querySelector ( '[data-testid=ss-mockups] [role=alert]' ); return a && a . textContent !== prev ; }, a1 , { timeout : 8000 }). catch (() => {});
const a2 = await alertText ();
ok ( 'oversize file rejected client-side with a message' , !! a2 && /limit|8\.0 MB|too (large|big)/i . test ( a2 ), a2 );
await page . waitForTimeout ( 400 );
ok ( 'oversize never reached the server' , srv . uploads . length === 1 , String ( srv . uploads . length ));
await sendMsg ( page , 'Build toward my mockup' );
await waitCanvas ( page , 'Mockup based heading' );
ok ( 'start body mockup_ids has the uploaded id' , Array . isArray ( srv . starts [ 0 ] ? . mockup_ids ) && srv . starts [ 0 ]. mockup_ids . includes ( srv . uploads [ 0 ]. id ), JSON . stringify ( srv . starts [ 0 ] ? . mockup_ids ));
});
scenario ( '5' , 'Sites I like CRUD, selection, never fetched' , async ({ page , name , ok }) => {
const label = ` ${ srv . url } /should-never-be-fetched` ;
srv . setNextResult ( pageResult ( 'Reference based heading' ));
await openModal ( page );
await expandSection ( page , 'sites' );
await page . click ( '[data-testid=ss-sites-i-like] button:has-text("Add a site I like")' );
await page . fill ( '#ss-ref-url' , label );
await page . fill ( '#ss-ref-likes' , 'Big photo header' );
await page . click ( '[data-testid=ss-ref-form] button:has-text("Add site")' );
await page . waitForSelector ( '[data-testid=ss-ref-item]' , { timeout : 8000 });
ok ( 'added entry listed' , ( await page . locator ( '[data-testid=ss-ref-item]' ). count ()) === 1 );
// edit
await page . click ( '[data-testid=ss-ref-item] button:has-text("Edit")' );
await page . fill ( '#ss-ref-likes' , 'Simple menu and white space' );
await page . click ( '[data-testid=ss-ref-form] button:has-text("Save changes")' );
await page . waitForFunction (() => document . querySelector ( '[data-testid=ss-ref-item]' ) ? . textContent . includes ( 'Simple menu' ), null , { timeout : 8000 });
ok ( 'edit saved (refs_update sent, text changed)' , srv . refCalls . some (( c ) => c . action === 'refs_update' ));
// a second entry, then delete it with confirmation
await page . click ( '[data-testid=ss-sites-i-like] button:has-text("Add a site I like")' );
await page . fill ( '#ss-ref-url' , 'second-example.test' );
await page . fill ( '#ss-ref-likes' , 'Nice footer' );
await page . click ( '[data-testid=ss-ref-form] button:has-text("Add site")' );
await page . waitForFunction (() => document . querySelectorAll ( '[data-testid=ss-ref-item]' ). length === 2 , null , { timeout : 8000 });
const second = page . locator ( '[data-testid=ss-ref-item]' , { hasText : 'second-example.test' });
await second . locator ( 'button:has-text("Delete")' ). click ();
ok ( 'delete asks for confirmation first' , ( await page . locator ( 'button:has-text("Confirm delete")' ). count ()) === 1 && ! srv . refCalls . some (( c ) => c . action === 'refs_delete' ));
await page . click ( 'button:has-text("Confirm delete")' );
await page . waitForFunction (() => document . querySelectorAll ( '[data-testid=ss-ref-item]' ). length === 1 , null , { timeout : 8000 });
ok ( 'delete removed the entry (refs_delete sent)' , srv . refCalls . some (( c ) => c . action === 'refs_delete' ));
// select + copy + send
await page . locator ( '[data-testid=ss-ref-item] input[type=checkbox]' ). check ();
const copy = page . locator ( '[data-testid=ss-sites-copy]' );
const copyTxt = ( await copy . textContent ()) ?? '' ;
ok ( '"does not open, visit or fetch it" copy visible' , ( await copy . isVisible ()) && /does not open, visit or fetch it/ . test ( copyTxt ), copyTxt );
await modalShot ( page , name , '04-sites-i-like.png' );
await sendMsg ( page , 'Use the sites I like' );
await waitCanvas ( page , 'Reference based heading' );
const refId = srv . refCalls . find (( c ) => c . action === 'refs_add' );
ok ( 'start body reference_ids has the selected id' , Array . isArray ( srv . starts [ 0 ] ? . reference_ids ) && srv . starts [ 0 ]. reference_ids . length === 1 , JSON . stringify ( srv . starts [ 0 ] ? . reference_ids ));
ok ( 'the label was stored as given' , !! refId && refId . body . url_label . includes ( 'should-never-be-fetched' ), JSON . stringify ( refId ? . body ));
ok ( 'mock never received a request for the label' , ! srv . requests . some (( r ) => r . path . includes ( 'should-never-be-fetched' )));
ok ( 'the browser never requested the label' , ! page . __urls . some (( u ) => u . includes ( 'should-never-be-fetched' )));
});
scenario ( '5b' , 'asset awareness' , async ({ page , name , ok }) => {
srv . setNextResult ( pageResult ( 'Asset aware heading' ));
await openModal ( page );
await expandSection ( page , 'assets' );
await page . waitForSelector ( '[data-testid=ss-asset-item]' , { timeout : 8000 });
ok ( 'three assets listed' , ( await page . locator ( '[data-testid=ss-asset-item]' ). count ()) === 3 );
const txt = ( await page . textContent ( '[data-testid=ss-assets]' )) ?? '' ;
ok ( 'captions shown' , txt . includes ( 'A wide blue banner photo' ) && txt . includes ( 'No description yet' ), txt . slice ( 0 , 200 ));
await page . waitForFunction (() => [... document . querySelectorAll ( '[data-testid=ss-asset-item] img' )]. every (( i ) => i . complete && i . naturalWidth > 0 ), null , { timeout : 8000 });
ok ( 'thumbnails loaded from same-origin URLs' , true );
await page . locator ( '[data-testid=ss-asset-item]' ). nth ( 2 ). locator ( 'input[type=checkbox]' ). uncheck ();
await modalShot ( page , name , '05-asset-awareness.png' );
await sendMsg ( page , 'Use my pictures' );
await waitCanvas ( page , 'Asset aware heading' );
const b = srv . starts [ 0 ] ?? {};
ok ( 'use_assets stays true and the excluded asset is left out' , b . use_assets === true && Array . isArray ( b . asset_filenames ) && b . asset_filenames . length === 2 && ! b . asset_filenames . includes ( 'logo.png' ), JSON . stringify ([ b . use_assets , b . asset_filenames ]));
});
scenario ( '6' , 'contrast repair' , async ({ page , name , ok }) => {
srv . setNextResult ( pageResult ( 'Contrast heading' , { id : 'c-h' , color : '#334155' , bg : '#1e3a8a' , size : '16px' , weight : '400' }));
await openModal ( page );
await sendMsg ( page , 'Make a blue banner' );
await waitCanvas ( page , 'Contrast heading' );
const c = await contrastIn ( page , 'Contrast heading' );
ok ( 'heading found in canvas' , !! c );
ok ( 'heading colour was changed from #334155' , !! c && c . color !== 'rgb(51, 65, 85)' , c ? . color );
ok ( 'heading now passes 4.5:1 on its background' , !! c && c . ratio >= 4.5 , JSON . stringify ( c ));
const adj = page . locator ( '[data-testid=ss-adjust]' );
await adj . waitFor ({ timeout : 5000 });
const t = ( await adj . textContent ()) ?? '' ;
ok ( 'adjustment summary visible and about readability' , ( await adj . isVisible ()) && /text colou?rs? .*(adjusted|readab)|readab/i . test ( t ), t . slice ( 0 , 200 ));
await modalShot ( page , name , '06-contrast-summary.png' );
});
scenario ( '7' , 'vision unavailable' , async ({ page , name , ok }) => {
srv . setVision ( false );
srv . setNextResult ( pageResult ( 'Text only heading' ));
await openModal ( page );
await expandSection ( page , 'mockups' );
await page . waitForSelector ( '[data-testid=ss-vision-note]' , { timeout : 8000 });
ok ( 'vision note shown' , await page . locator ( '[data-testid=ss-vision-note]' ). isVisible ());
ok ( 'file input disabled' , await page . locator ( 'input[data-testid=ss-mockup-input]' ). evaluate (( e ) => e . disabled ));
ok ( 'self-review toggle disabled' , await page . locator ( '[data-testid=ss-selfreview-toggle] input' ). evaluate (( e ) => e . disabled && ! e . checked ));
await modalShot ( page , name , '07-vision-unavailable.png' );
await sendMsg ( page , 'Just words please' );
await waitCanvas ( page , 'Text only heading' );
ok ( 'text-only send works end to end' , srv . starts . length === 1 && ! srv . starts [ 0 ]. mockup_ids , JSON . stringify ( srv . starts [ 0 ] ? . mockup_ids ));
});
2026-11-01 12:16:59 -08:00
scenario ( '9' , 'Clear chat: confirm clears the history (and stays cleared after reopening)' , async ({ page , name , ok }) => {
srv . setNextResult ( pageResult ( 'Heading before clearing' ));
await openModal ( page );
await sendMsg ( page , 'Please remember this line' );
await waitCanvas ( page , 'Heading before clearing' );
const dlg = page . locator ( '[role=dialog]' );
await page . waitForFunction (() => ( document . querySelector ( '[role=dialog]' ) ? . textContent ?? '' ). includes ( 'Please remember this line' ), null , { timeout : 15000 });
await page . waitForSelector ( '[role=dialog] button:has-text("Clear chat")' , { timeout : 10000 });
await page . click ( '[role=dialog] button:has-text("Clear chat")' );
2026-11-01 15:38:54 -08:00
ok ( 'first click asks for confirmation (no request yet)' , ( await dlg . locator ( 'button:has-text("Clear for everyone on this site?")' ). count ()) === 1 && srv . clearCalls . length === 0 , String ( srv . clearCalls . length ));
await page . click ( '[role=dialog] button:has-text("Clear for everyone on this site?")' );
2026-11-01 12:16:59 -08:00
await page . waitForFunction (() => ! ( document . querySelector ( '[role=dialog]' ) ? . textContent ?? '' ). includes ( 'Please remember this line' ), null , { timeout : 10000 }). catch (() => {});
const txt = ( await dlg . textContent ()) ?? '' ;
ok ( 'confirm sends exactly one clear_history' , srv . clearCalls . length === 1 , String ( srv . clearCalls . length ));
ok ( 'the history is gone from the modal after the confirm' , ! txt . includes ( 'Please remember this line' ), txt . slice ( 0 , 200 ));
ok ( 'no error shown' , ( await page . locator ( '[data-testid=ss-error]' ). count ()) === 0 );
await page . click ( '[role=dialog] button[aria-label=Close]' );
await openModal ( page );
await page . waitForTimeout ( 800 );
ok ( 'still cleared after closing and reopening the modal' , ! ((( await page . locator ( '[role=dialog]' ). textContent ()) ?? '' ). includes ( 'Please remember this line' )));
await modalShot ( page , name , '09-cleared.png' );
});
scenario ( '9b' , 'Clear chat while a job is running: a plain message, nothing lost' , async ({ page , ok }) => {
srv . setNextResult ( pageResult ( 'First heading' ));
await openModal ( page );
await sendMsg ( page , 'First request' );
await waitCanvas ( page , 'First heading' );
await page . waitForSelector ( '[role=dialog] button:has-text("Clear chat")' , { timeout : 10000 });
srv . setActiveJob ({ phase : 'generating' });
srv . setSlow ( true );
await page . click ( '[role=dialog] button:has-text("Clear chat")' );
2026-11-01 15:38:54 -08:00
await page . click ( '[role=dialog] button:has-text("Clear for everyone on this site?")' );
2026-11-01 12:16:59 -08:00
await page . waitForSelector ( '[data-testid=ss-error]' , { timeout : 10000 });
const err = ( await page . textContent ( '[data-testid=ss-error]' )) ?? '' ;
ok ( 'the refusal is a readable sentence, not an error code' , /still working/i . test ( err ) && ! /job_in_progress/ . test ( err ), err );
srv . release ();
});
scenario ( '9c' , 'Clear chat is disabled while Sitesmith is working' , async ({ page , ok }) => {
srv . setNextResult ( pageResult ( 'Done heading' ));
await openModal ( page );
await sendMsg ( page , 'One' );
await waitCanvas ( page , 'Done heading' );
await page . waitForSelector ( '[data-testid=ss-clear-chat]:not([disabled])' , { timeout : 10000 });
srv . setSlow ( true );
srv . setNextResult ( pageResult ( 'Later heading' ));
await sendMsg ( page , 'Two' );
await page . waitForSelector ( '[data-testid=ss-job-status]' , { timeout : 10000 });
ok ( 'while a job runs the button is disabled' , await page . locator ( '[data-testid=ss-clear-chat]' ). isDisabled ());
srv . release ();
await waitCanvas ( page , 'Later heading' );
await page . waitForSelector ( '[data-testid=ss-clear-chat]:not([disabled])' , { timeout : 10000 });
ok ( 'enabled again when the job has finished' , true );
});
2026-11-01 10:48:12 -08:00
scenario ( '8' , 'self-review round trip (one round)' , async ({ page , name , ok }) => {
srv . setNextResult ( pageResult ( 'First pass heading' , { id : 'fp-h' }));
srv . setNextResult ({ type : 'patch' , message : 'Fixed spacing.' , ops : [{ op : 'update_props' , node_id : 'fp-h' , props : { text : 'Reviewed heading' } }] });
await openModal ( page );
await sendMsg ( page , 'Build and then check it' );
await page . waitForSelector ( '[data-testid=ss-review-note]' , { timeout : 45000 });
const note = ( await page . textContent ( '[data-testid=ss-review-note]' )) ?? '' ;
ok ( 'review note says it made fixes' , /made fixes/ . test ( note ), note );
await waitCanvas ( page , 'Reviewed heading' );
ok ( 'patch from the second job applied' , true );
ok ( 'two start jobs' , srv . starts . length === 2 , String ( srv . starts . length ));
const up = srv . uploads [ 0 ];
2026-11-01 11:47:04 -08:00
ok ( 'render uploaded through review_render_upload (kind review_render) as a JPEG named self-review-render.jpg, > 2000 bytes' ,
!! up && up . mime === 'image/jpeg' && up . kind === 'review_render' && up . name === 'self-review-render.jpg' && up . size > 2000 , JSON . stringify ( up ));
2026-11-01 10:48:12 -08:00
const s2 = srv . starts [ 1 ] ?? {};
ok ( 'second job message starts "Review the attached screenshot"' , typeof s2 . message === 'string' && s2 . message . startsWith ( 'Review the attached screenshot' ), String ( s2 . message ). slice ( 0 , 60 ));
2026-11-01 11:10:30 -08:00
ok ( 'second job is intent "review" carrying the upload id in review_image_ids (not mockup_ids)' , s2 . intent === 'review' && Array . isArray ( s2 . review_image_ids ) && s2 . review_image_ids . includes ( up ? . id ) && ! s2 . mockup_ids , JSON . stringify ({ intent : s2 . intent , review_image_ids : s2 . review_image_ids , mockup_ids : s2 . mockup_ids }));
2026-11-01 10:48:12 -08:00
ok ( 'second job has use_assets false' , s2 . use_assets === false , String ( s2 . use_assets ));
ok ( 'render image deleted afterwards' , srv . imageDeletes . includes ( up ? . id ), JSON . stringify ( srv . imageDeletes ));
await page . waitForTimeout ( 2500 );
ok ( 'no third job' , srv . starts . length === 2 , String ( srv . starts . length ));
await modalShot ( page , name , '08-self-review-done.png' );
}, { selfReview : true });
2026-11-01 20:40:09 -08:00
// Round 6 (live 2026.11.10): the review summary listed only top-level sections (children of Section/ColumnLayout live
// in Craft linkedNodes), so every proposal was stripped. A NESTED page is built in the real editor, the real
// summarizeCanvas() output goes through the REAL WHP review lockdown (php: sitesmith_review_rebuild_summary +
// Sitesmith::reviewFilterOps, WHP_BACKEND_DIR), the "model" patches the nested heading by its server alias, and the
// edit must take effect on the canvas while a link/text edit, an unknown id and a delete are dropped with reasons.
const WHP_BACKEND_DIR = process . env . WHP_BACKEND_DIR || '/workspace/whp-wt-sitesmith-r6' ;
scenario ( '8n' , 'self-review patches a NESTED node through the real WHP review lockdown' , async ({ page , name , ok }) => {
const info = [];
// console values as JSON (Firefox prints an object argument as "JSHandle@object" in text())
page . on ( 'console' , async ( m ) => { if ( m . type () === 'info' ) info . push (( await Promise . all ( m . args (). map (( x ) => x . jsonValue (). catch (() => null )))). map (( v ) => ( typeof v === 'string' ? v : JSON . stringify ( v ))). join ( ' ' )); });
srv . setNextResult ({ type : 'replace' , scope : 'page' , message : 'Built it.' , pages : [{ name : 'Home' , tree : el ( 'Container' , { node_id : 'nr-root' , style : { padding : '0px' } }, [
el ( 'Section' , { node_id : 'nr-sec' , aiName : 'Hero' , style : { backgroundColor : '#0f172a' , paddingTop : '48px' , paddingBottom : '48px' } }, [
el ( 'Heading' , { node_id : 'nr-h' , text : 'Nested review heading' , level : 'h1' , style : { color : '#ffffff' , fontSize : '40px' } }),
el ( 'ColumnLayout' , { node_id : 'nr-cols' , columns : 2 }, [
el ( 'Container' , { node_id : 'nr-c1' }, [ el ( 'ButtonLink' , { node_id : 'nr-btn' , text : 'Nested button' , href : '/contact' , style : { backgroundColor : '#fde047' , color : '#0f172a' } })]),
el ( 'Container' , { node_id : 'nr-c2' }, [ el ( 'TextBlock' , { node_id : 'nr-t' , text : 'Second column' , style : { color : '#ffffff' } })]),
]),
]),
]) }] });
let backend = null ;
srv . setNextResultFn (( body ) => {
const alias = ( real ) => Object . entries ( backend ? . alias ?? {}). find (([, r ]) => r === real ) ? .[ 0 ] ?? 'missing' ;
backend = JSON . parse ( execFileSync ( 'php' , [ path . join ( root , 'scripts/e2e-sitesmith-review-backend.php' ), WHP_BACKEND_DIR ], { input : JSON . stringify ({ summary : body . canvas_summary , proposed : [] }) }). toString ());
const proposed = [
{ op : 'update_props' , node_id : alias ( 'nr-h' ), props : { style : { color : '#fde047' } } },
{ op : 'update_props' , node_id : alias ( 'nr-btn' ), props : { href : 'https://evil.example/x' , text : 'Hacked' } },
{ op : 'update_props' , node_id : 'n999' , props : { style : { color : '#ff0000' } } },
{ op : 'delete_node' , node_id : alias ( 'nr-t' ) },
];
const f = JSON . parse ( execFileSync ( 'php' , [ path . join ( root , 'scripts/e2e-sitesmith-review-backend.php' ), WHP_BACKEND_DIR ], { input : JSON . stringify ({ summary : body . canvas_summary , proposed }) }). toString ());
backend = { ... backend , filtered : f };
return { ok : true , response : { type : 'patch' , message : 'Recoloured the hero heading.' , ops : f . ops }, review_ops : { proposed : proposed . length , kept : f . ops . length , dropped : f . dropped } };
});
await openModal ( page );
await sendMsg ( page , 'Build my hero and check it' );
await page . waitForSelector ( '[data-testid=ss-review-note]' , { timeout : 45000 });
const note = ( await page . textContent ( '[data-testid=ss-review-note]' )) ?? '' ;
const sent = srv . starts [ 1 ] ? . canvas_summary ?? '' ;
ok ( 'the editor summary lists the NESTED nodes (heading, button, column text inside the Section)' , /id=nr-h / . test ( sent ) && /id=nr-btn / . test ( sent ) && /id=nr-t / . test ( sent ), sent . slice ( 0 , 600 ));
const hLine = ( backend ? . summary ?? '' ). split ( '\n' ). find (( l ) => / id = n \ d + \ { style \ . color = # ffffff , style \ . fontSize = 40 px , level = h1 \ } $ / . test ( l )) ?? '' ;
ok ( 'the WHP-rebuilt summary shows the heading indented under its Section, with its colour, without its text' , /^ {4,}- Heading id=n\d+ / . test ( hLine ) && ! /Nested review heading|Nested button|contact/ . test ( backend ? . summary ?? '' ), backend ? . summary );
ok ( 'WHP kept exactly the nested heading edit, mapped back to its real id' , JSON . stringify ( backend ? . filtered ? . ops ) === JSON . stringify ([{ op : 'update_props' , node_id : 'nr-h' , props : { style : { color : '#fde047' } } }]), JSON . stringify ( backend ? . filtered ));
await page . waitForFunction (() => {
const h = [... document . querySelectorAll ( '.canvas-device-frame h1' )]. find (( e ) => e . textContent . includes ( 'Nested review heading' ));
return h && getComputedStyle ( h ). color === 'rgb(253, 224, 71)' ;
}, null , { timeout : 10000 }). then (() => ok ( 'the nested heading really changed colour on the canvas' , true ), ( e ) => ok ( 'the nested heading really changed colour on the canvas' , false , String ( e ). slice ( 0 , 120 )));
const btn = await page . evaluate (() => { const a = [... document . querySelectorAll ( '.canvas-device-frame a' )]. find (( e ) => / Nested button | Hacked / . test ( e . textContent )); return a ? { text : a . textContent . trim (), href : a . getAttribute ( 'href' ) } : null ; });
ok ( 'the button text and link were NOT changed (forbidden for a review)' , btn ? . text === 'Nested button' && btn ? . href === '/contact' , JSON . stringify ( btn ));
ok ( 'the column text was NOT deleted' , ( await canvasText ( page )). includes ( 'Second column' ));
ok ( 'the note says it made fixes and why the rest were dropped' , /made fixes/ . test ( note ) && note . includes ( '3 did not pass the safety checks (1 named a part that is not on the page, 1 tried to change text, a link or an image, 1 tried to add, remove or replace a block)' ), note );
const logLine = info . find (( t ) => t . startsWith ( '[sitesmith] self-review dropped ops' )) ?? '' ;
ok ( 'the console logs the drop counts, without any of the model\'s words' , /unknown_id/ . test ( logLine ) && ! /evil|Hacked|Recoloured/ . test ( info . join ( ' ' )), logLine );
await modalShot ( page , name , '08n-nested-review.png' );
}, { selfReview : true });
2026-11-01 10:48:12 -08:00
scenario ( '8b' , 'self-review skipped when the upload fails' , async ({ page , ok }) => {
srv . setUploadFail ( 500 );
srv . setNextResult ( pageResult ( 'Kept first pass' , { id : 'kp-h' }));
await openModal ( page );
await sendMsg ( page , 'Build it' );
await page . waitForSelector ( '[data-testid=ss-review-note]' , { timeout : 45000 });
const note = ( await page . textContent ( '[data-testid=ss-review-note]' )) ?? '' ;
ok ( 'non-blocking "Self-review skipped" note' , /Self-review skipped/ . test ( note ), note );
ok ( 'first job content still applied' , ( await canvasText ( page )). includes ( 'Kept first pass' ));
ok ( 'no second job' , srv . starts . length === 1 , String ( srv . starts . length ));
ok ( 'no blocking error shown' , ( await page . locator ( '[data-testid=ss-error]' ). count ()) === 0 );
}, { selfReview : true });
// ------------------------------------------------------------------ run
async function run ( name , launcher , exe ) {
const browser = await launcher . launch ({ executablePath : exe || undefined , ...( name === 'chromium' ? { args : [ '--no-sandbox' ] } : {}) });
for ( const sc of scenarios ) {
if ( process . env . SITESMITH_SCENARIO && ! process . env . SITESMITH_SCENARIO . split ( ',' ). includes ( sc . id )) continue ;
srv . reset ();
const label = ` ${ sc . id } ${ sc . title } ` ;
let page ;
try {
page = await newPage ( browser , name , sc . opts );
await sc . fn ({ page , name , ok : ( l , c , d ) => log ( name , label , !! c , l , d ) });
await page . waitForTimeout ( 200 );
const bad = await page . evaluate (() => window . __bad );
log ( name , label , page . __errors . length === 0 , 'no uncaught page errors' , page . __errors . join ( ' | ' ));
log ( name , label , bad . length === 0 , 'no NetworkError / Failed to fetch text in the modal' , JSON . stringify ( bad ));
const ext = page . __external . filter (( u ) => ! /\.css(\?|$)|fonts\.(googleapis|gstatic)/ . test ( u ));
if ( ext . length ) console . log ( `[ ${ name } ] note ${ label } : ${ ext . length } external request(s) aborted locally` );
} catch ( e ) {
log ( name , label , false , 'scenario completed' , String ( e ? . message ?? e ). split ( '\n' ). slice ( 0 , 3 ). join ( ' ' ));
if ( page && name === 'firefox' ) await page . screenshot ({ path : path . join ( shotsDir , `fail- ${ sc . id } .png` ) }). catch (() => {});
} finally {
await page ? . __ctx . close (). catch (() => {});
}
}
await browser . close ();
}
const only = process . env . SITESMITH_ONLY ;
if ( only !== 'firefox' ) await run ( 'chromium' , chromium , process . env . CHROMIUM_PATH );
if ( only !== 'chromium' ) await run ( 'firefox' , firefox , process . env . FIREFOX_PATH );
await srv . close ();
console . log ( failures ? `\n ${ failures } FAILURE(S)` : '\nALL PASS' );
process . exit ( failures ? 1 : 0 );