diff --git a/craft/package.json b/craft/package.json index da09908..af04b3d 100644 --- a/craft/package.json +++ b/craft/package.json @@ -13,6 +13,7 @@ "test:unit": "vitest run", "test:browser:bg": "node scripts/e2e-background.mjs", "test:browser:canvas": "node scripts/e2e-canvas-parity.mjs", + "test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs", "test:unit:watch": "vitest" }, "dependencies": { diff --git a/craft/scripts/e2e-sitesmith.mjs b/craft/scripts/e2e-sitesmith.mjs new file mode 100644 index 0000000..777a3b4 --- /dev/null +++ b/craft/scripts/e2e-sitesmith.mjs @@ -0,0 +1,381 @@ +#!/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= where to build (default: a fresh temp dir) + * SITESMITH_E2E_SKIP_BUILD=1 reuse an existing build in SITESMITH_E2E_DIST + * SITESMITH_SHOTS= Firefox screenshots go here (default: /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'; + +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)); +}); + +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]; + ok('render uploaded as a JPEG mockup named self-review-render.jpg, > 2000 bytes', + !!up && up.mime === 'image/jpeg' && up.kind === 'mockup' && up.name === 'self-review-render.jpg' && up.size > 2000, JSON.stringify(up)); + 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)); + ok('second job carries the upload id in mockup_ids', Array.isArray(s2.mockup_ids) && s2.mockup_ids.includes(up?.id), JSON.stringify(s2.mockup_ids)); + 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 }); + +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);