Merge feat/sitesmith-ui into release/sitesmith-colors
Conflict: craft/package.json scripts (kept mobile, colour and sitesmith e2e entries). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ae092990ca
32 files changed
+3875
-51
No files matched your search
@@ -0,0 +1,16 @@
|
||||
// Bundled by scripts/e2e-capture-security.mjs and run in real Firefox + Chromium.
|
||||
import { captureHtml } from '../src/utils/self-review-capture';
|
||||
import { CAPTURE_CSP } from '../src/utils/sanitize-capture-html';
|
||||
|
||||
(window as any).captureHtml = captureHtml;
|
||||
/** Defence-in-depth probe: the capture frame WITHOUT the sanitiser (sandbox + CSP only). */
|
||||
(window as any).rawFrame = (html: string) => new Promise<void>((resolve) => {
|
||||
const f = document.createElement('iframe');
|
||||
f.setAttribute('sandbox', 'allow-same-origin');
|
||||
const CSP = CAPTURE_CSP;
|
||||
f.setAttribute('csp', CSP);
|
||||
f.style.cssText = 'position:fixed;left:-9999px;width:800px;height:600px';
|
||||
f.addEventListener('load', () => setTimeout(resolve, 800), { once: true });
|
||||
f.srcdoc = html.replace('<head>', `<head><meta http-equiv="Content-Security-Policy" content="${CSP}">`);
|
||||
document.body.appendChild(f);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-browser proof (Firefox + Chromium) that the self-review capture cannot run or leak hostile page
|
||||
* markup. A malicious fixture goes through the WHOLE capture path; sentinels: window.__pwned in the
|
||||
* parent, any request to /beacon or evil.example, parent cookie/localStorage reachability, frame
|
||||
* navigation. A second probe feeds the SAME hostile page to the frame WITHOUT the sanitiser to show the
|
||||
* sandbox + CSP alone also hold. Benign pages must still capture.
|
||||
* Run: node scripts/e2e-capture-security.mjs (CHROMIUM_PATH / FIREFOX_PATH override the binaries)
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import { chromium, firefox } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const out = await build({
|
||||
entryPoints: [path.join(root, 'scripts/e2e-capture-security-entry.ts')],
|
||||
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error',
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
});
|
||||
const js = out.outputFiles[0].text;
|
||||
|
||||
const PWN = "(function(){try{parent.__pwned=1;top.__pwned=1}catch(e){}try{fetch('/beacon?js')}catch(e){}try{top.postMessage('pwned','*')}catch(e){}try{new Image().src='/beacon?img-js'}catch(e){}})()";
|
||||
const EVIL = `<!doctype html><html><head>
|
||||
<meta http-equiv="refresh" content="0;url=/beacon?refresh"><base href="https://evil.example/">
|
||||
<link rel="import" href="/beacon?import"><link rel="stylesheet" href="/beacon?css"><link rel="preload" href="/beacon?preload" as="script">
|
||||
<style>@import url(/beacon?atimport); body{background:url(https://evil.example/bg.png);} .pad{padding:8px}</style>
|
||||
<script>${PWN}</script></head>
|
||||
<body onload="${PWN}" style="font:16px sans-serif">
|
||||
<h1 class="pad" style="color:#123">Hostile but readable</h1>
|
||||
<img src="x" onerror="${PWN}"><img src="/beacon?img">
|
||||
<svg width="10" height="10" onload="${PWN}"><script>${PWN}</script><a xlink:href="javascript:${PWN}"><rect width="5" height="5"/></a></svg>
|
||||
<a href="javascript:${PWN}">js link</a><form action="javascript:${PWN}"><button formaction="javascript:${PWN}">b</button></form>
|
||||
<iframe srcdoc="<script>${PWN}</script>"></iframe><iframe src="/beacon?iframe"></iframe>
|
||||
<object data="/beacon?object"></object><embed src="/beacon?embed">
|
||||
<div style="background:url(/beacon?cssurl)">x</div><video src="/beacon?video"></video>
|
||||
<input autofocus onfocus="${PWN}"><details open ontoggle="${PWN}"><summary>s</summary></details>
|
||||
<style>
|
||||
.b1{background:u\\72l(/beacon?b-escape)} .b2{background-image:image-set("/beacon?b-imageset" 1x)} .b3{background:url("x)/beacon?b-paren")}
|
||||
.b4{background:ur/**/l(/beacon?b-comment)} :root{--u:url(/beacon?b-var)} .b5{background:var(--u)}
|
||||
@font-face{font-family:bx;src:url(/beacon?b-fontface)} .b6{font-family:bx}
|
||||
@font-face{font-family:by;src:url(/api/site-builder.php?action=delete_asset)} .b7{font-family:by}
|
||||
.b8{background:url(assets/%2e%2e/beacon?b-pct)} .b9{background:url(assets/%252e%252e/beacon?b-pct2)} .b10{background:url(assets/..%2fbeacon?b-pct3)}
|
||||
.b11{background:url(javascript:1)} .b12{background:url(blob:http://x/y)} .b13{background:url("data:text/html,<b>1</b>")} .b14{-moz-binding:url(/beacon?b-moz)}
|
||||
@keyframes bk{from{background:url(/beacon?b-kf)}to{color:red}} @media (min-width:1px){.b15{background:url(/beacon?b-media)}}
|
||||
</style>
|
||||
<div class="b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15" style="background:u\\72l(/beacon?i-escape);background-image:image-set('/beacon?i-set' 1x)">css bypass probes</div>
|
||||
<img src="assets/%2e%2e/beacon?p1"><img src="assets/%252e%252e/beacon?p2"><img src="assets/..%2fbeacon?p3"><img src="assets/../beacon?p4"><img src="//localhost/beacon?p5"><img src="data:text/html,x"><img src="blob:http://x/y"><img src="assets\\..\\beacon?p6">
|
||||
<p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p>
|
||||
</body></html>`;
|
||||
const BENIGN = `<!doctype html><html><head><style>body{margin:0;font:20px sans-serif}.h{background:linear-gradient(135deg,#0f172a,#3b82f6);color:#fff;padding:40px}</style></head>
|
||||
<body><div class="h"><h1>Benign page</h1><p>${'Hello world. '.repeat(30)}</p></div></body></html>`;
|
||||
|
||||
const beacons = [];
|
||||
const srv = http.createServer((q, r) => {
|
||||
const p = q.url.split('?')[0];
|
||||
if (p === '/') { r.setHeader('content-type', 'text/html'); return r.end('<!doctype html><title>host</title><body>host</body>'); }
|
||||
if (p === '/beacon') { beacons.push(q.url); r.statusCode = 204; return r.end(); }
|
||||
r.statusCode = 404; r.end('nf');
|
||||
}).listen(0);
|
||||
const port = srv.address().port;
|
||||
|
||||
let failed = 0;
|
||||
const check = (name, label, ok, extra = '') => { if (!ok) failed++; console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); };
|
||||
|
||||
async function run(name, launcher, exe) {
|
||||
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const evilReq = [];
|
||||
await ctx.route(/evil\.example/, (route) => { evilReq.push(route.request().url()); route.abort(); });
|
||||
// Only requests that actually reach the network count (Playwright also reports CSP-blocked ones as 'request').
|
||||
let messages = 0;
|
||||
await page.exposeFunction('__onMsg', () => { messages++; });
|
||||
await page.goto(`http://localhost:${port}/`);
|
||||
await page.evaluate(() => { document.cookie = 'sess=SECRET; path=/'; localStorage.setItem('csrf', 'SECRET'); window.addEventListener('message', (e) => { if (e.data === 'pwned') window.__onMsg(); }); });
|
||||
await page.addScriptTag({ content: js });
|
||||
|
||||
// 1) hostile page through the whole capture path
|
||||
beacons.length = 0;
|
||||
const res = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size } : r; }, EVIL);
|
||||
await page.waitForTimeout(500);
|
||||
const pwned = await page.evaluate(() => window.__pwned);
|
||||
check(name, 'hostile page: parent sentinel window.__pwned untouched', pwned === undefined, `(${pwned})`);
|
||||
check(name, 'hostile page: no postMessage to the editor', messages === 0, `(${messages})`);
|
||||
check(name, 'hostile page: ZERO requests to the panel origin /beacon (every CSS/URL/encoding bypass class)', beacons.length === 0, JSON.stringify(beacons));
|
||||
check(name, 'hostile page: no request to evil.example', evilReq.length === 0, JSON.stringify(evilReq));
|
||||
check(name, 'hostile page: capture result is safe (ok, or refused) and frame removed', (res.ok || typeof res.reason === 'string') && (await page.evaluate(() => document.querySelectorAll('iframe').length)) === 0, JSON.stringify(res));
|
||||
check(name, 'hostile page: sanitised content still captured (readable page)', res.ok === true && res.size > 3000, JSON.stringify(res));
|
||||
|
||||
// 2) defence in depth: same hostile page, NO sanitiser, only sandbox + CSP
|
||||
beacons.length = 0; evilReq.length = 0; messages = 0;
|
||||
await page.evaluate((h) => window.rawFrame(h), EVIL);
|
||||
await page.waitForTimeout(500);
|
||||
const pwned2 = await page.evaluate(() => window.__pwned);
|
||||
check(name, 'unsanitised probe: sandbox+CSP alone stop script execution (__pwned)', pwned2 === undefined, `(${pwned2})`);
|
||||
check(name, 'unsanitised probe: no postMessage', messages === 0);
|
||||
// Same-origin passive GETs (img/css url) are allowed by CSP 'self'; that is why the sanitiser also restricts them. Script-initiated ones must never occur.
|
||||
check(name, 'unsanitised probe: CSP alone (img-src data:) lets no request reach the panel origin', beacons.length === 0, JSON.stringify(beacons));
|
||||
check(name, 'unsanitised probe: no evil.example requests', evilReq.length === 0, JSON.stringify(evilReq));
|
||||
await page.evaluate(() => document.querySelectorAll('iframe').forEach((f) => f.remove()));
|
||||
|
||||
// 3) benign page still captures
|
||||
const ok = await page.evaluate(async (h) => { const r = await window.captureHtml(h, { settleMs: 300 }); return r.ok ? { ok: true, size: r.blob.size, w: r.width, h: r.height } : r; }, BENIGN);
|
||||
check(name, 'benign page captures', ok.ok === true && ok.size > 3000, JSON.stringify(ok));
|
||||
await browser.close();
|
||||
}
|
||||
await run('chromium', chromium, process.env.CHROMIUM_PATH);
|
||||
await run('firefox', firefox, process.env.FIREFOX_PATH);
|
||||
srv.close();
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Mock editor + Sitesmith backend for real-browser tests.
|
||||
*
|
||||
* Serves a BUILT editor (vite dist), injects window.WHP_CONFIG into index.html, answers
|
||||
* /api/site-builder.php (load/save/list_assets stubs) and implements the Sitesmith backend contract
|
||||
* (web-files/api/sitesmith.php, docs/sitesmith-backend.md) at /api/sitesmith.php?action=NAME.
|
||||
* No network access beyond 127.0.0.1; deterministic; every request is recorded.
|
||||
*
|
||||
* const srv = await createMockServer({ distDir, project });
|
||||
* srv.setNextResult({ type: 'patch', ops: [], message: 'x' }); // queue a job result
|
||||
* srv.starts // recorded `start` bodies
|
||||
* await srv.close();
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const MIME = {
|
||||
'.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.png': 'image/png',
|
||||
'.svg': 'image/svg+xml', '.json': 'application/json', '.woff2': 'font/woff2', '.map': 'application/json',
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ PNG helpers (also used by the test)
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
|
||||
return t;
|
||||
})();
|
||||
const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
||||
return Buffer.concat([len, td, crc]);
|
||||
}
|
||||
/** A valid solid-colour RGB PNG. */
|
||||
export function makePng(w = 32, h = 32, [r, g, b] = [99, 102, 241]) {
|
||||
const row = Buffer.alloc(1 + w * 3);
|
||||
for (let x = 0; x < w; x++) { row[1 + x * 3] = r; row[2 + x * 3] = g; row[3 + x * 3] = b; }
|
||||
const raw = Buffer.concat(Array.from({ length: h }, () => row));
|
||||
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
||||
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
function sniff(buf) {
|
||||
if (buf.length > 24 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
return { mime: 'image/png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
let i = 2, width = null, height = null;
|
||||
while (i + 9 < buf.length) {
|
||||
if (buf[i] !== 0xff) { i++; continue; }
|
||||
const m = buf[i + 1];
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) { height = buf.readUInt16BE(i + 5); width = buf.readUInt16BE(i + 7); break; }
|
||||
i += 2 + buf.readUInt16BE(i + 2);
|
||||
}
|
||||
return { mime: 'image/jpeg', width, height };
|
||||
}
|
||||
if (buf.length > 6 && buf.subarray(0, 3).toString() === 'GIF') return { mime: 'image/gif', width: null, height: null };
|
||||
if (buf.length > 12 && buf.subarray(0, 4).toString() === 'RIFF' && buf.subarray(8, 12).toString() === 'WEBP') return { mime: 'image/webp', width: null, height: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minimal multipart/form-data parser: scans the raw body for the boundary. */
|
||||
function parseMultipart(buf, contentType) {
|
||||
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
|
||||
if (!m) return [];
|
||||
const delim = Buffer.from('--' + (m[1] || m[2]));
|
||||
const parts = [];
|
||||
let pos = buf.indexOf(delim);
|
||||
while (pos !== -1) {
|
||||
const next = buf.indexOf(delim, pos + delim.length);
|
||||
if (next === -1) break;
|
||||
// part = [pos+delim+CRLF, next-CRLF)
|
||||
const part = buf.subarray(pos + delim.length + 2, next - 2);
|
||||
const sep = part.indexOf('\r\n\r\n');
|
||||
if (sep !== -1) {
|
||||
const head = part.subarray(0, sep).toString('utf8');
|
||||
const nameM = /name="([^"]*)"/.exec(head);
|
||||
const fileM = /filename="([^"]*)"/.exec(head);
|
||||
parts.push({ name: nameM ? nameM[1] : '', filename: fileM ? fileM[1] : null, data: part.subarray(sep + 4) });
|
||||
}
|
||||
pos = next;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
const PHASES = ['queued', 'preparing', 'captioning', 'generating', 'finalizing'];
|
||||
|
||||
export const DEFAULT_PROJECT = (() => {
|
||||
const node = (resolvedName, props, nodes = [], parent = null, isCanvas = false, extra = {}) =>
|
||||
({ type: { resolvedName }, isCanvas, props, displayName: resolvedName, custom: {}, hidden: false, parent, nodes, linkedNodes: {}, ...extra });
|
||||
const craft = {
|
||||
ROOT: node('Container', { style: { minHeight: '400px', backgroundColor: '#ffffff' }, tag: 'div' }, ['sec1'], null, true),
|
||||
sec1: node('Container', { style: { backgroundColor: '#1e3a8a', padding: '40px' }, tag: 'section', node_id: 'sec1' }, ['head1'], 'ROOT', true),
|
||||
head1: node('Heading', { text: 'Original heading', level: 'h1', style: { color: '#ffffff', fontSize: '36px' }, node_id: 'head1' }, [], 'sec1'),
|
||||
};
|
||||
return {
|
||||
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: JSON.stringify(craft) }],
|
||||
design: {},
|
||||
extras: {},
|
||||
};
|
||||
})();
|
||||
|
||||
export const DEFAULT_ASSETS = [
|
||||
{ filename: 'hero.png', caption: 'A wide blue banner photo', alt: 'Banner', width: 64, height: 32, color: [30, 58, 138] },
|
||||
{ filename: 'team.png', caption: 'The team standing outside the shop', alt: null, width: 48, height: 48, color: [16, 185, 129] },
|
||||
{ filename: 'logo.png', caption: null, alt: null, width: 32, height: 32, color: [245, 158, 11] },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {{distDir: string, port?: number, project?: object, host?: string}} o
|
||||
*/
|
||||
export async function createMockServer(o) {
|
||||
const distDir = o.distDir;
|
||||
const project = o.project ?? DEFAULT_PROJECT;
|
||||
const csrf = 'x';
|
||||
const siteId = 1;
|
||||
|
||||
const s = {
|
||||
requests: [], // every request: {method, path, action, body?}
|
||||
starts: [], // parsed `start` bodies
|
||||
cancels: [], // parsed `cancel` bodies
|
||||
uploads: [], // {fields, name, size, mime, id}
|
||||
imageDeletes: [], // ids
|
||||
refCalls: [], // {action, body}
|
||||
saves: 0,
|
||||
};
|
||||
|
||||
// ---- scriptable state
|
||||
let vision = true;
|
||||
let phaseDelayMs = 300;
|
||||
let slow = false; // jobs stay in `generating` until release()/cancel
|
||||
let uploadFailStatus = 0; // non-zero: ref_image_upload answers this status with a JSON error
|
||||
let enabled = true;
|
||||
const results = []; // queued results for upcoming jobs
|
||||
const jobs = new Map(); // job_id -> job
|
||||
const images = new Map(); // id -> {meta, bytes, mime}
|
||||
let refs = [];
|
||||
let refSeq = 1;
|
||||
let activeJobId = null;
|
||||
|
||||
const defaultResult = () => ({
|
||||
ok: true,
|
||||
response: {
|
||||
type: 'replace', scope: 'section', message: 'Default mock result',
|
||||
pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: { node_id: 'mock-default', style: { padding: '20px' } }, nodes: [{ type: { resolvedName: 'Heading' }, props: { node_id: 'mock-default-h', text: 'Default mock heading', style: { color: '#111111' } }, nodes: [] }] } }],
|
||||
},
|
||||
});
|
||||
|
||||
const toResult = (r) => (r && typeof r === 'object' && 'ok' in r ? r : { ok: true, response: r });
|
||||
|
||||
function newJob(result) {
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
const job = { id, created: Math.floor(Date.now() / 1000), startedAt: Date.now(), step: 0, lastAdvance: 0, status: 'running', result: toResult(result ?? results.shift() ?? defaultResult()), polls: 0 };
|
||||
jobs.set(id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
function stepJob(job) {
|
||||
job.polls++;
|
||||
if (job.status !== 'running') return;
|
||||
if (slow) { job.step = Math.min(Math.max(job.step, 1), 3); if (job.step < 3) job.step++; return; }
|
||||
const now = Date.now();
|
||||
if (now - job.lastAdvance < phaseDelayMs * 0.8) return;
|
||||
job.lastAdvance = now;
|
||||
job.step++;
|
||||
if (job.step >= PHASES.length) job.status = 'done';
|
||||
}
|
||||
|
||||
const jobView = (job) => {
|
||||
const phase = job.status === 'done' ? 'done' : job.status === 'cancelled' ? 'done' : PHASES[Math.min(job.step, PHASES.length - 1)];
|
||||
return { job_id: job.id, status: job.status, phase, elapsed_s: Math.floor((Date.now() - job.startedAt) / 1000), created_at: job.created };
|
||||
};
|
||||
|
||||
const imageMeta = (im) => im.meta;
|
||||
|
||||
// ---- public controls
|
||||
Object.assign(s, {
|
||||
/** Queue the result of the next job (a bare response object, or a full {ok,...} result). */
|
||||
setNextResult(resp, notes) { const r = toResult(resp); if (notes) r.notes = notes; results.push(r); },
|
||||
clearResults() { results.length = 0; },
|
||||
setPhaseDelayMs(ms) { phaseDelayMs = ms; },
|
||||
setSlow(v) { slow = !!v; },
|
||||
setVision(v) { vision = !!v; },
|
||||
setUploadFail(status) { uploadFailStatus = status | 0; },
|
||||
setEnabled(v) { enabled = !!v; },
|
||||
/** Pretend a job is already running (as after a page reload). It advances with polls like any job. */
|
||||
setActiveJob(o2 = {}) {
|
||||
const job = newJob(o2.result);
|
||||
if (o2.phase) job.step = Math.max(0, PHASES.indexOf(o2.phase));
|
||||
if (o2.elapsed_s) job.startedAt = Date.now() - o2.elapsed_s * 1000;
|
||||
activeJobId = job.id;
|
||||
return job.id;
|
||||
},
|
||||
/** Let a `setSlow(true)` job finish on its next poll. */
|
||||
release() { slow = false; for (const j of jobs.values()) if (j.status === 'running') j.step = PHASES.length - 1; },
|
||||
/** Forget refs/images/jobs/recordings between scenarios (keeps knobs at their defaults). */
|
||||
reset() {
|
||||
for (const k of ['requests', 'starts', 'cancels', 'uploads', 'imageDeletes', 'refCalls']) s[k].length = 0;
|
||||
results.length = 0; jobs.clear(); images.clear(); refs = []; refSeq = 1; activeJobId = null;
|
||||
vision = true; phaseDelayMs = 300; slow = false; uploadFailStatus = 0; enabled = true;
|
||||
},
|
||||
get imageCount() { return images.size; },
|
||||
get storedImages() { return [...images.values()].map((i) => ({ ...i.meta, mime: i.mime })); },
|
||||
requestsTo(pathPrefix) { return s.requests.filter((r) => r.path.startsWith(pathPrefix)); },
|
||||
});
|
||||
|
||||
// ---- handlers
|
||||
const json = (res, code, body) => { res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
|
||||
const readBody = (req) => new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c))); });
|
||||
|
||||
async function sitesmith(req, res, u, action) {
|
||||
const isPost = req.method === 'POST';
|
||||
const raw = isPost ? await readBody(req) : Buffer.alloc(0);
|
||||
const ct = req.headers['content-type'] || '';
|
||||
const rec = { method: req.method, path: u.pathname, action, query: Object.fromEntries(u.searchParams) };
|
||||
s.requests.push(rec);
|
||||
if (isPost && req.headers['x-csrf-token'] !== csrf) return json(res, 403, { ok: false, error: 'csrf', message: 'Your session has expired. Refresh the page.' });
|
||||
let body = {};
|
||||
if (isPost && !ct.startsWith('multipart/')) { try { body = JSON.parse(raw.toString('utf8') || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad_json', message: 'Bad request.' }); } }
|
||||
rec.body = body;
|
||||
|
||||
switch (action) {
|
||||
case 'entitlement':
|
||||
return json(res, 200, { ok: true, summary: { enabled, status: enabled ? 'OK_MONTHLY' : 'DISABLED', monthly_used: 0, monthly_cap: 50, bonus_credits: 0, resets_on: '2026-12-01' }, profile_supported: true, vision });
|
||||
case 'capabilities':
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
vision: { available: vision, source: vision ? 'primary' : 'none', message: vision ? '' : 'Image features are not available on this server, so Sitesmith cannot look at pictures yet. Describing what you want in words works as usual.' },
|
||||
features: { mockups: vision, reference_screenshots: vision, captions: vision, self_review: vision },
|
||||
limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 },
|
||||
});
|
||||
case 'history': {
|
||||
const aj = activeJobId ? jobs.get(activeJobId) : null;
|
||||
const live = aj && aj.status === 'running' ? jobView(aj) : null;
|
||||
return json(res, 200, { ok: true, messages: [], active_job: live ? { job_id: live.job_id, status: live.status, phase: live.phase, elapsed_s: live.elapsed_s, created_at: live.created_at } : null });
|
||||
}
|
||||
case 'job_active': {
|
||||
const aj = activeJobId ? jobs.get(activeJobId) : null;
|
||||
return json(res, 200, { ok: true, job: aj && aj.status === 'running' ? jobView(aj) : null });
|
||||
}
|
||||
case 'start': {
|
||||
s.starts.push(body);
|
||||
for (const j of jobs.values()) if (j.status === 'running') return json(res, 409, { ok: false, error: 'job_in_progress', status: 'JOB_IN_PROGRESS', message: 'Sitesmith is already working on a request for this site.', job_id: j.id });
|
||||
const job = newJob();
|
||||
activeJobId = job.id;
|
||||
return json(res, 200, { ok: true, job_id: job.id, status: 'running', phase: 'queued', created_at: job.created });
|
||||
}
|
||||
case 'poll': {
|
||||
const job = jobs.get(u.searchParams.get('job_id') || '');
|
||||
if (!job) return json(res, 404, { ok: false, error: 'job_not_found', message: 'That job could not be found.' });
|
||||
stepJob(job);
|
||||
const v = jobView(job);
|
||||
if (job.status === 'cancelled') return json(res, 200, { ...v, ok: true, result: { ok: false, status: 'CANCELLED', message: 'The request was cancelled.' } });
|
||||
if (job.status === 'done') { if (activeJobId === job.id) activeJobId = null; return json(res, 200, { ...v, ok: true, result: job.result }); }
|
||||
return json(res, 200, { ...v, ok: true, retry_after_ms: phaseDelayMs });
|
||||
}
|
||||
case 'cancel': {
|
||||
s.cancels.push(body);
|
||||
const job = jobs.get(body.job_id);
|
||||
if (!job) return json(res, 404, { ok: false, error: 'job_not_found', message: 'That job could not be found.' });
|
||||
if (job.status === 'running') job.status = 'cancelled';
|
||||
if (activeJobId === job.id) activeJobId = null;
|
||||
return json(res, 200, { ok: true, status: job.status });
|
||||
}
|
||||
case 'refs_list':
|
||||
return json(res, 200, { ok: true, references: refs, images: [...images.values()].map(imageMeta) });
|
||||
case 'refs_add': {
|
||||
s.refCalls.push({ action, body });
|
||||
const now = new Date().toISOString();
|
||||
const r = { id: refSeq++, url_label: String(body.url_label ?? ''), what_i_like: String(body.what_i_like ?? ''), screenshot_ids: body.screenshot_ids ?? [], created_at: now, updated_at: now };
|
||||
refs.push(r);
|
||||
return json(res, 200, { ok: true, reference: r });
|
||||
}
|
||||
case 'refs_update': {
|
||||
s.refCalls.push({ action, body });
|
||||
const r = refs.find((x) => x.id === body.id);
|
||||
if (!r) return json(res, 404, { ok: false, error: 'not_found', message: 'Reference not found.' });
|
||||
for (const k of ['url_label', 'what_i_like', 'screenshot_ids']) if (k in body) r[k] = body[k];
|
||||
r.updated_at = new Date().toISOString();
|
||||
return json(res, 200, { ok: true, reference: r });
|
||||
}
|
||||
case 'refs_delete':
|
||||
s.refCalls.push({ action, body });
|
||||
refs = refs.filter((x) => x.id !== body.id);
|
||||
return json(res, 200, { ok: true });
|
||||
case 'ref_image_upload': {
|
||||
const parts = parseMultipart(raw, ct);
|
||||
const fields = Object.fromEntries(parts.filter((p) => p.filename === null).map((p) => [p.name, p.data.toString('utf8')]));
|
||||
const file = parts.find((p) => p.name === 'file' && p.filename !== null);
|
||||
s.uploads.push({ fields, name: file?.filename ?? null, size: file?.data.length ?? 0, head: file ? file.data.subarray(0, 4).toString('hex') : '' });
|
||||
if (uploadFailStatus) return json(res, uploadFailStatus, { ok: false, error: 'server_error', message: 'The server had a problem saving that picture. Try again in a minute.' });
|
||||
if (!file) return json(res, 400, { ok: false, error: 'no_file', message: 'No file was uploaded.' });
|
||||
const info = sniff(file.data);
|
||||
if (!info) return json(res, 415, { ok: false, error: 'unsupported_type', message: 'That file is not a picture Sitesmith can use. Use a JPEG, PNG, WebP or GIF.' });
|
||||
const id = crypto.randomBytes(16).toString('hex');
|
||||
const meta = { id, kind: fields.kind === 'screenshot' ? 'screenshot' : 'mockup', name: file.filename || 'image', width: info.width, height: info.height, size: file.data.length, created_at: new Date().toISOString() };
|
||||
images.set(id, { meta, bytes: file.data, mime: info.mime });
|
||||
s.uploads[s.uploads.length - 1].id = id;
|
||||
s.uploads[s.uploads.length - 1].mime = info.mime;
|
||||
s.uploads[s.uploads.length - 1].kind = meta.kind;
|
||||
return json(res, 200, { ok: true, image: meta });
|
||||
}
|
||||
case 'ref_image_delete':
|
||||
s.imageDeletes.push(body.id);
|
||||
images.delete(body.id);
|
||||
return json(res, 200, { ok: true });
|
||||
case 'ref_image': {
|
||||
const im = images.get(u.searchParams.get('id') || '');
|
||||
if (!im) { res.statusCode = 404; return res.end('nf'); }
|
||||
res.statusCode = 200; res.setHeader('content-type', im.mime); res.setHeader('x-content-type-options', 'nosniff');
|
||||
return res.end(im.bytes);
|
||||
}
|
||||
case 'assets':
|
||||
return json(res, 200, { ok: true, assets: DEFAULT_ASSETS.map((a) => ({ filename: a.filename, relUrl: `assets/${a.filename}`, url: `/mock-assets/${a.filename}`, width: a.width, height: a.height, caption: a.caption, alt: a.alt })) });
|
||||
default:
|
||||
return json(res, 400, { ok: false, error: 'unknown_action', message: 'Unknown action.' });
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const action = u.searchParams.get('action') || '';
|
||||
if (u.pathname === '/api/sitesmith.php') return await sitesmith(req, res, u, action);
|
||||
s.requests.push({ method: req.method, path: u.pathname, action });
|
||||
if (u.pathname === '/api/site-builder.php') {
|
||||
if (req.method === 'POST') await readBody(req);
|
||||
if (action === 'load') return json(res, 200, { success: true, rev: 'r1', limits: {}, project });
|
||||
if (action === 'save') { s.saves++; return json(res, 200, { success: true, rev: 'r' + (s.saves + 1) }); }
|
||||
if (action === 'list_assets') return json(res, 200, { success: true, assets: [] });
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
if (u.pathname.startsWith('/mock-assets/')) {
|
||||
const a = DEFAULT_ASSETS.find((x) => x.filename === path.basename(u.pathname));
|
||||
if (a) { res.setHeader('content-type', 'image/png'); return res.end(makePng(a.width, a.height, a.color)); }
|
||||
}
|
||||
const p = u.pathname === '/' ? '/index.html' : u.pathname;
|
||||
if (p === '/index.html') {
|
||||
const cfg = { user: 't', apiUrl: '/api/site-builder.php', csrfToken: csrf, siteId, siteDomain: 'olsdigital.com', siteName: 'OLS', backUrl: '/', isRoot: false };
|
||||
const h = fs.readFileSync(path.join(distDir, 'index.html'), 'utf8').replace('</head>', () => `<script>window.WHP_CONFIG=${JSON.stringify(cfg)};</script></head>`);
|
||||
res.setHeader('content-type', 'text/html'); return res.end(h);
|
||||
}
|
||||
const f = path.join(distDir, path.normalize(p));
|
||||
if (f.startsWith(distDir) && fs.existsSync(f) && fs.statSync(f).isFile()) {
|
||||
res.setHeader('content-type', MIME[path.extname(f)] || 'application/octet-stream');
|
||||
return res.end(fs.readFileSync(f));
|
||||
}
|
||||
res.statusCode = 404; res.end('nf');
|
||||
} catch (e) {
|
||||
res.statusCode = 500; res.end(String(e));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(o.port ?? 0, o.host ?? '127.0.0.1', resolve));
|
||||
s.port = server.address().port;
|
||||
s.url = `http://127.0.0.1:${s.port}`;
|
||||
s.close = () => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });
|
||||
return s;
|
||||
}
|
||||
|
||||
// Standalone: node scripts/e2e-sitesmith-mock-server.mjs <distDir> [port]
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const srv = await createMockServer({ distDir: path.resolve(process.argv[2] || 'dist'), port: Number(process.argv[3] || 0) });
|
||||
console.log('mock sitesmith server up', srv.url);
|
||||
}
|
||||
@@ -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=<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';
|
||||
|
||||
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);
|
||||
Reference in new issue
Block a user