1. summarizeCanvas() walked only node.nodes, so everything inside Section/ColumnLayout/forms (linkedNodes) was missing and live self-reviews could only name top-level sections. It now walks nodes + linked slots (stable key order) and lists id, type and key props (text first, links, main style values as style.<key>, colour-like props) as key="value", within a documented budget (150 nodes, 6000 chars, 300 chars of props per line; detail is dropped before nodes; cuts happen at line boundaries -- a mid-line cut used to make the server refuse the whole review). HtmlBlock code is no longer summarised. applyPatch no longer counts an op with nothing left to set as a change; ops that did nothing are counted per reason. The self-review note and console say why ops were dropped (server and editor reasons, counts only). Real-browser test 8n builds a nested page, sends the real summary through the real WHP review lockdown (php) and checks the nested edit takes effect while link/text edits, unknown ids and deletes are dropped. 3. Background placeholders use ?text=%E2%80%8B (measured: placehold.co then draws no glyphs; the same-colour label left a 4-colour ghost in its PNG); the capture draws no <text> for a zero-width or same-colour label. Pixel test on the case-4 hero markup in both browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
494 lines
33 KiB
JavaScript
494 lines
33 KiB
JavaScript
#!/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';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
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('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")');
|
|
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?")');
|
|
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")');
|
|
await page.click('[role=dialog] button:has-text("Clear for everyone on this site?")');
|
|
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);
|
|
});
|
|
|
|
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 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));
|
|
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 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 }));
|
|
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 });
|
|
|
|
// 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=40px, 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 });
|
|
|
|
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);
|