diff --git a/craft/package-lock.json b/craft/package-lock.json index 07fd023..f7bea9a 100644 --- a/craft/package-lock.json +++ b/craft/package-lock.json @@ -20,6 +20,7 @@ "@craftjs/core": "^0.2.10", "@craftjs/layers": "^0.2.7", "dompurify": "^3.4.5", + "html-to-image": "1.11.13", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -2349,6 +2350,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", diff --git a/craft/package.json b/craft/package.json index bb63423..1e910b9 100644 --- a/craft/package.json +++ b/craft/package.json @@ -16,6 +16,7 @@ "test:browser:mobile": "node scripts/e2e-mobile-parity.mjs", "test:browser:colorpick": "node scripts/e2e-color-pick.mjs", "test:browser:colors": "node scripts/e2e-color-system.mjs", + "test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs", "test:unit:watch": "vitest" }, "dependencies": { @@ -31,6 +32,7 @@ "@craftjs/core": "^0.2.10", "@craftjs/layers": "^0.2.7", "dompurify": "^3.4.5", + "html-to-image": "1.11.13", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/craft/scripts/e2e-capture-security-entry.ts b/craft/scripts/e2e-capture-security-entry.ts new file mode 100644 index 0000000..5ad3533 --- /dev/null +++ b/craft/scripts/e2e-capture-security-entry.ts @@ -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((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('', ``); + document.body.appendChild(f); +}); diff --git a/craft/scripts/e2e-capture-security.mjs b/craft/scripts/e2e-capture-security.mjs new file mode 100644 index 0000000..1f54196 --- /dev/null +++ b/craft/scripts/e2e-capture-security.mjs @@ -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 = ` + + + + + +

Hostile but readable

+ + +js link
+ + +
x
+
s
+ +
css bypass probes
+ +

Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}

+`; +const BENIGN = ` +

Benign page

${'Hello world. '.repeat(30)}

`; + +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('hosthost'); } + 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); diff --git a/craft/scripts/e2e-sitesmith-mock-server.mjs b/craft/scripts/e2e-sitesmith-mock-server.mjs new file mode 100644 index 0000000..248cb1a --- /dev/null +++ b/craft/scripts/e2e-sitesmith-mock-server.mjs @@ -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('', () => ``); + 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 [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); +} diff --git a/craft/scripts/e2e-sitesmith.mjs b/craft/scripts/e2e-sitesmith.mjs new file mode 100644 index 0000000..777a3b4 --- /dev/null +++ b/craft/scripts/e2e-sitesmith.mjs @@ -0,0 +1,381 @@ +#!/usr/bin/env node +/** + * Real-browser (Firefox AND Chromium, headless) end-to-end test of the Sitesmith modal. + * + * Builds the editor to a scratch dir, serves it from a mock server that implements the Sitesmith + * backend contract (scripts/e2e-sitesmith-mock-server.mjs), and drives the REAL modal: job + * start/poll/cancel/resume, mockup + reference upload, "Sites I like", asset awareness, contrast + * repair, vision-unavailable mode and the one-round self-review (real html-to-image capture). + * No real host or AI provider is contacted: every non-127.0.0.1 request is answered locally. + * + * npm run test:browser:sitesmith + * + * Env: + * CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries + * SITESMITH_E2E_DIST= where to build (default: a fresh temp dir) + * SITESMITH_E2E_SKIP_BUILD=1 reuse an existing build in SITESMITH_E2E_DIST + * SITESMITH_SHOTS= Firefox screenshots go here (default: /sitesmith-shots) + * SITESMITH_ONLY=chromium|firefox run one browser + * SITESMITH_SCENARIO=3,5b run only these scenario ids + */ +import { chromium, firefox } from '@playwright/test'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createMockServer, makePng } from './e2e-sitesmith-mock-server.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const distDir = path.resolve(process.env.SITESMITH_E2E_DIST || fs.mkdtempSync(path.join(os.tmpdir(), 'sitesmith-e2e-'))); +const shotsDir = path.resolve(process.env.SITESMITH_SHOTS || path.join(os.tmpdir(), 'sitesmith-shots')); +fs.mkdirSync(shotsDir, { recursive: true }); + +if (!(process.env.SITESMITH_E2E_SKIP_BUILD && fs.existsSync(path.join(distDir, 'index.html')))) { + console.log(`building editor -> ${distDir}`); + const b = spawnSync('npx', ['vite', 'build', '--outDir', distDir, '--emptyOutDir'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); + if (b.status !== 0) { console.error(b.stdout, b.stderr); process.exit(2); } +} + +const srv = await createMockServer({ distDir }); + +// ------------------------------------------------------------------ result builders + +const el = (name, props, nodes = []) => ({ type: { resolvedName: name }, props, nodes }); +const pageResult = (headingText, { id = 'new-h', color = '#ffffff', bg = '#0f172a', size = '36px', weight = '700' } = {}) => ({ + type: 'replace', scope: 'page', message: 'Built it.', + pages: [{ name: 'Home', tree: el('Container', { node_id: 'new-root', style: { minHeight: '300px', backgroundColor: '#ffffff' } }, [ + el('Container', { node_id: id + '-sec', tag: 'section', style: { backgroundColor: bg, padding: '40px' } }, [ + el('Heading', { node_id: id, text: headingText, level: 'h1', style: { color, fontSize: size, fontWeight: weight } }), + ]), + ]) }], +}); + +// ------------------------------------------------------------------ harness + +let failures = 0; +const log = (b, s, ok, label, detail) => { + if (!ok) failures++; + console.log(`[${b}] ${ok ? 'PASS' : 'FAIL'} ${s}: ${label}${!ok && detail !== undefined ? ` -- ${detail}` : ''}`); +}; + +const INIT = `(() => { + window.__phases = []; window.__bad = []; + const BAD = /NetworkError|Failed to fetch/; + const scan = () => { + const st = document.querySelector('[data-testid=ss-job-status]'); + if (st) { + const ph = st.getAttribute('data-phase') + '|' + (st.querySelector('[data-testid=ss-phase-label]')?.textContent || ''); + if (window.__phases[window.__phases.length - 1] !== ph) window.__phases.push(ph); + } + for (const d of document.querySelectorAll('[role=dialog]')) { + const t = d.textContent || ''; + const m = BAD.exec(t); + if (m && window.__bad.length < 5) window.__bad.push(t.slice(Math.max(0, m.index - 40), m.index + 60)); + } + }; + new MutationObserver(scan).observe(document, { subtree: true, childList: true, characterData: true, attributes: true }); + try { if (!window.__keepSelfReview) localStorage.setItem('sitesmith.selfReview', 'off'); } catch (e) {} +})();`; + +async function newPage(browser, name, { selfReview = false } = {}) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const external = []; + await ctx.route(/^https?:\/\/(?!127\.0\.0\.1)/, (route) => { + const u = route.request().url(); + external.push(u); + if (/\.css(\?|$)|fonts\.googleapis/.test(u)) return route.fulfill({ status: 200, contentType: 'text/css', headers: { 'access-control-allow-origin': '*' }, body: '/* offline */' }); + return route.abort(); + }); + await ctx.addInitScript(selfReview ? `window.__keepSelfReview = true;${INIT}` : INIT); + const page = await ctx.newPage(); + const errors = []; + const seenUrls = []; + page.on('pageerror', (e) => errors.push(String(e))); + page.on('request', (r) => seenUrls.push(r.url())); + page.__errors = errors; page.__urls = seenUrls; page.__external = external; page.__ctx = ctx; + await page.goto(srv.url + '/'); + await page.waitForSelector('.canvas-device-frame [data-craft-container]', { timeout: 20000 }); + await page.waitForTimeout(500); + return page; +} + +async function openModal(page, { waitInput = true } = {}) { + await page.click('button.sitesmith-btn'); + await page.waitForSelector('[role=dialog]', { timeout: 10000 }); + if (!waitInput) return; + await page.waitForSelector('textarea.sitesmith-textarea:not([disabled])', { timeout: 10000 }); +} +const sendMsg = async (page, text) => { await page.fill('textarea.sitesmith-textarea', text); await page.click('[role=dialog] button:has-text("→")'); }; +const canvasText = (page) => page.evaluate(() => document.querySelector('.canvas-device-frame')?.innerText ?? ''); +const waitCanvas = (page, text, timeout = 20000) => + page.waitForFunction((t) => (document.querySelector('.canvas-device-frame')?.innerText ?? '').includes(t), text, { timeout }); +const expandSection = async (page, id) => { + const btn = page.locator(`[data-testid=ss-section-${id}] > button`); + if ((await btn.getAttribute('aria-expanded')) !== 'true') await btn.click(); +}; +const modalShot = async (page, name, file) => { if (name === 'firefox') await page.screenshot({ path: path.join(shotsDir, file) }); }; +const contrastIn = (page, text) => page.evaluate((t) => { + const lum = ([r, g, b]) => { const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; + const rgba = (s) => { const m = s.match(/[\d.]+/g).map(Number); return { c: m.slice(0, 3), a: m.length > 3 ? m[3] : 1 }; }; + const h = [...document.querySelectorAll('.canvas-device-frame h1,.canvas-device-frame h2')].find((e) => e.textContent.includes(t)); + if (!h) return null; + const fg = rgba(getComputedStyle(h).color); + let bg = null; + for (let n = h; n; n = n.parentElement) { const b = rgba(getComputedStyle(n).backgroundColor); if (b.a > 0.99) { bg = b.c; break; } } + bg = bg || [255, 255, 255]; + const [a, b] = [lum(fg.c), lum(bg)]; + return { color: getComputedStyle(h).color, bg: `rgb(${bg.join(', ')})`, ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) }; +}, text); + +// ------------------------------------------------------------------ scenarios + +const scenarios = []; +const scenario = (id, title, fn, opts) => scenarios.push({ id, title, fn, opts }); + +scenario('1', 'start -> poll progress -> result applied', async ({ page, name, ok }) => { + srv.setNextResult(pageResult('Brand new heading')); + await openModal(page); + await modalShot(page, name, '01-idle.png'); + await sendMsg(page, 'Build me a home page'); + await page.waitForSelector('[data-testid=ss-job-status][data-phase=generating]', { timeout: 15000 }); + await modalShot(page, name, '02-progress.png'); + await waitCanvas(page, 'Brand new heading'); + ok('result applied to the canvas', true); + const phases = await page.evaluate(() => window.__phases); + const names = phases.map((p) => p.split('|')[0]); + ok('successive phases seen', ['queued', 'preparing', 'captioning', 'generating', 'finalizing'].filter((p) => names.includes(p)).length >= 4, JSON.stringify(phases)); + ok('phase labels are shown as text', phases.some((p) => /Designing your page/.test(p)) && phases.some((p) => /Preparing/.test(p)), JSON.stringify(phases)); + const body = srv.starts[0] ?? {}; + ok('start body has site_id', body.site_id === 1, JSON.stringify(body.site_id)); + ok('start body has design.primaryColor', typeof body.design?.primaryColor === 'string', JSON.stringify(body.design)); + ok('exactly one start (self-review off)', srv.starts.length === 1, String(srv.starts.length)); +}); + +scenario('2', 'cancel a slow job', async ({ page, name, ok }) => { + srv.setSlow(true); + srv.setNextResult(pageResult('Should never appear')); + await openModal(page); + const before = await canvasText(page); + await sendMsg(page, 'Something slow'); + await page.waitForSelector('[data-testid=ss-cancel]', { timeout: 10000 }); + await page.waitForSelector('[data-testid=ss-job-status][data-phase=generating]', { timeout: 10000 }); + ok('input replaced by status (no textarea while running)', (await page.locator('textarea.sitesmith-textarea').count()) === 0); + await modalShot(page, name, '09-running-disabled.png'); + await page.click('[data-testid=ss-cancel]'); + await page.waitForSelector('[data-testid=ss-note]', { timeout: 15000 }); + ok('mock recorded the cancel', srv.cancels.length === 1 && typeof srv.cancels[0].job_id === 'string', JSON.stringify(srv.cancels)); + const note = await page.textContent('[data-testid=ss-note]'); + ok('UI shows a Cancelled note', /Cancelled/.test(note ?? ''), note); + ok('canvas unchanged', (await canvasText(page)) === before && !(await canvasText(page)).includes('Should never appear')); +}); + +scenario('3', 'reload with an active job resumes it', async ({ page, ok }) => { + srv.setActiveJob({ phase: 'generating', elapsed_s: 4, result: { ok: true, response: pageResult('Resumed heading') } }); + await page.reload(); + await page.waitForSelector('.canvas-device-frame [data-craft-container]', { timeout: 20000 }); + await openModal(page, { waitInput: false }); // a resumed job replaces the input box + await page.waitForSelector('[data-testid=ss-job-status]', { timeout: 15000 }); + const txt = await page.textContent('[data-testid=ss-job-status]'); + ok('status shows the picked-up text', /Picked up a request that was already running/.test(txt ?? ''), txt); + await waitCanvas(page, 'Resumed heading'); + ok('result applied after completion', true); + ok('no new start was made', srv.starts.length === 0, String(srv.starts.length)); +}); + +scenario('4', 'mockup upload + client-side rejections', async ({ page, name, ok }) => { + srv.setNextResult(pageResult('Mockup based heading')); + await openModal(page); + await expandSection(page, 'mockups'); + const input = page.locator('input[data-testid=ss-mockup-input]'); + await input.setInputFiles({ name: 'mock.png', mimeType: 'image/png', buffer: makePng(64, 48) }); + await page.waitForSelector('[data-testid=ss-mockup-item] img', { timeout: 10000 }); + ok('one upload reached the server', srv.uploads.length === 1 && !!srv.uploads[0].id, JSON.stringify(srv.uploads)); + await page.waitForFunction(() => { const i = document.querySelector('[data-testid=ss-mockup-item] img'); return i && i.complete && i.naturalWidth > 0; }, null, { timeout: 10000 }); + ok('thumbnail rendered from the server', true); + await modalShot(page, name, '03-mockup-attached.png'); + + const alertText = () => page.locator('[data-testid=ss-mockups] [role=alert]').first().textContent(); + await input.setInputFiles({ name: 'x.png', mimeType: 'image/png', buffer: Buffer.from('this is plain text, not a picture at all') }); + await page.waitForSelector('[data-testid=ss-mockups] [role=alert]', { timeout: 5000 }); + const a1 = await alertText(); + ok('non-image rejected client-side with a message', !!a1 && a1.length > 5, a1); + await page.waitForTimeout(400); + ok('non-image never reached the server', srv.uploads.length === 1, String(srv.uploads.length)); + + await input.setInputFiles({ name: 'big.png', mimeType: 'image/png', buffer: Buffer.concat([makePng(8, 8), Buffer.alloc(9 * 1024 * 1024)]) }); + await page.waitForFunction((prev) => { const a = document.querySelector('[data-testid=ss-mockups] [role=alert]'); return a && a.textContent !== prev; }, a1, { timeout: 8000 }).catch(() => {}); + const a2 = await alertText(); + ok('oversize file rejected client-side with a message', !!a2 && /limit|8\.0 MB|too (large|big)/i.test(a2), a2); + await page.waitForTimeout(400); + ok('oversize never reached the server', srv.uploads.length === 1, String(srv.uploads.length)); + + await sendMsg(page, 'Build toward my mockup'); + await waitCanvas(page, 'Mockup based heading'); + ok('start body mockup_ids has the uploaded id', Array.isArray(srv.starts[0]?.mockup_ids) && srv.starts[0].mockup_ids.includes(srv.uploads[0].id), JSON.stringify(srv.starts[0]?.mockup_ids)); +}); + +scenario('5', 'Sites I like CRUD, selection, never fetched', async ({ page, name, ok }) => { + const label = `${srv.url}/should-never-be-fetched`; + srv.setNextResult(pageResult('Reference based heading')); + await openModal(page); + await expandSection(page, 'sites'); + await page.click('[data-testid=ss-sites-i-like] button:has-text("Add a site I like")'); + await page.fill('#ss-ref-url', label); + await page.fill('#ss-ref-likes', 'Big photo header'); + await page.click('[data-testid=ss-ref-form] button:has-text("Add site")'); + await page.waitForSelector('[data-testid=ss-ref-item]', { timeout: 8000 }); + ok('added entry listed', (await page.locator('[data-testid=ss-ref-item]').count()) === 1); + // edit + await page.click('[data-testid=ss-ref-item] button:has-text("Edit")'); + await page.fill('#ss-ref-likes', 'Simple menu and white space'); + await page.click('[data-testid=ss-ref-form] button:has-text("Save changes")'); + await page.waitForFunction(() => document.querySelector('[data-testid=ss-ref-item]')?.textContent.includes('Simple menu'), null, { timeout: 8000 }); + ok('edit saved (refs_update sent, text changed)', srv.refCalls.some((c) => c.action === 'refs_update')); + // a second entry, then delete it with confirmation + await page.click('[data-testid=ss-sites-i-like] button:has-text("Add a site I like")'); + await page.fill('#ss-ref-url', 'second-example.test'); + await page.fill('#ss-ref-likes', 'Nice footer'); + await page.click('[data-testid=ss-ref-form] button:has-text("Add site")'); + await page.waitForFunction(() => document.querySelectorAll('[data-testid=ss-ref-item]').length === 2, null, { timeout: 8000 }); + const second = page.locator('[data-testid=ss-ref-item]', { hasText: 'second-example.test' }); + await second.locator('button:has-text("Delete")').click(); + ok('delete asks for confirmation first', (await page.locator('button:has-text("Confirm delete")').count()) === 1 && !srv.refCalls.some((c) => c.action === 'refs_delete')); + await page.click('button:has-text("Confirm delete")'); + await page.waitForFunction(() => document.querySelectorAll('[data-testid=ss-ref-item]').length === 1, null, { timeout: 8000 }); + ok('delete removed the entry (refs_delete sent)', srv.refCalls.some((c) => c.action === 'refs_delete')); + // select + copy + send + await page.locator('[data-testid=ss-ref-item] input[type=checkbox]').check(); + const copy = page.locator('[data-testid=ss-sites-copy]'); + const copyTxt = (await copy.textContent()) ?? ''; + ok('"does not open, visit or fetch it" copy visible', (await copy.isVisible()) && /does not open, visit or fetch it/.test(copyTxt), copyTxt); + await modalShot(page, name, '04-sites-i-like.png'); + await sendMsg(page, 'Use the sites I like'); + await waitCanvas(page, 'Reference based heading'); + const refId = srv.refCalls.find((c) => c.action === 'refs_add'); + ok('start body reference_ids has the selected id', Array.isArray(srv.starts[0]?.reference_ids) && srv.starts[0].reference_ids.length === 1, JSON.stringify(srv.starts[0]?.reference_ids)); + ok('the label was stored as given', !!refId && refId.body.url_label.includes('should-never-be-fetched'), JSON.stringify(refId?.body)); + ok('mock never received a request for the label', !srv.requests.some((r) => r.path.includes('should-never-be-fetched'))); + ok('the browser never requested the label', !page.__urls.some((u) => u.includes('should-never-be-fetched'))); +}); + +scenario('5b', 'asset awareness', async ({ page, name, ok }) => { + srv.setNextResult(pageResult('Asset aware heading')); + await openModal(page); + await expandSection(page, 'assets'); + await page.waitForSelector('[data-testid=ss-asset-item]', { timeout: 8000 }); + ok('three assets listed', (await page.locator('[data-testid=ss-asset-item]').count()) === 3); + const txt = (await page.textContent('[data-testid=ss-assets]')) ?? ''; + ok('captions shown', txt.includes('A wide blue banner photo') && txt.includes('No description yet'), txt.slice(0, 200)); + await page.waitForFunction(() => [...document.querySelectorAll('[data-testid=ss-asset-item] img')].every((i) => i.complete && i.naturalWidth > 0), null, { timeout: 8000 }); + ok('thumbnails loaded from same-origin URLs', true); + await page.locator('[data-testid=ss-asset-item]').nth(2).locator('input[type=checkbox]').uncheck(); + await modalShot(page, name, '05-asset-awareness.png'); + await sendMsg(page, 'Use my pictures'); + await waitCanvas(page, 'Asset aware heading'); + const b = srv.starts[0] ?? {}; + ok('use_assets stays true and the excluded asset is left out', b.use_assets === true && Array.isArray(b.asset_filenames) && b.asset_filenames.length === 2 && !b.asset_filenames.includes('logo.png'), JSON.stringify([b.use_assets, b.asset_filenames])); +}); + +scenario('6', 'contrast repair', async ({ page, name, ok }) => { + srv.setNextResult(pageResult('Contrast heading', { id: 'c-h', color: '#334155', bg: '#1e3a8a', size: '16px', weight: '400' })); + await openModal(page); + await sendMsg(page, 'Make a blue banner'); + await waitCanvas(page, 'Contrast heading'); + const c = await contrastIn(page, 'Contrast heading'); + ok('heading found in canvas', !!c); + ok('heading colour was changed from #334155', !!c && c.color !== 'rgb(51, 65, 85)', c?.color); + ok('heading now passes 4.5:1 on its background', !!c && c.ratio >= 4.5, JSON.stringify(c)); + const adj = page.locator('[data-testid=ss-adjust]'); + await adj.waitFor({ timeout: 5000 }); + const t = (await adj.textContent()) ?? ''; + ok('adjustment summary visible and about readability', (await adj.isVisible()) && /text colou?rs? .*(adjusted|readab)|readab/i.test(t), t.slice(0, 200)); + await modalShot(page, name, '06-contrast-summary.png'); +}); + +scenario('7', 'vision unavailable', async ({ page, name, ok }) => { + srv.setVision(false); + srv.setNextResult(pageResult('Text only heading')); + await openModal(page); + await expandSection(page, 'mockups'); + await page.waitForSelector('[data-testid=ss-vision-note]', { timeout: 8000 }); + ok('vision note shown', await page.locator('[data-testid=ss-vision-note]').isVisible()); + ok('file input disabled', await page.locator('input[data-testid=ss-mockup-input]').evaluate((e) => e.disabled)); + ok('self-review toggle disabled', await page.locator('[data-testid=ss-selfreview-toggle] input').evaluate((e) => e.disabled && !e.checked)); + await modalShot(page, name, '07-vision-unavailable.png'); + await sendMsg(page, 'Just words please'); + await waitCanvas(page, 'Text only heading'); + ok('text-only send works end to end', srv.starts.length === 1 && !srv.starts[0].mockup_ids, JSON.stringify(srv.starts[0]?.mockup_ids)); +}); + +scenario('8', 'self-review round trip (one round)', async ({ page, name, ok }) => { + srv.setNextResult(pageResult('First pass heading', { id: 'fp-h' })); + srv.setNextResult({ type: 'patch', message: 'Fixed spacing.', ops: [{ op: 'update_props', node_id: 'fp-h', props: { text: 'Reviewed heading' } }] }); + await openModal(page); + await sendMsg(page, 'Build and then check it'); + await page.waitForSelector('[data-testid=ss-review-note]', { timeout: 45000 }); + const note = (await page.textContent('[data-testid=ss-review-note]')) ?? ''; + ok('review note says it made fixes', /made fixes/.test(note), note); + await waitCanvas(page, 'Reviewed heading'); + ok('patch from the second job applied', true); + ok('two start jobs', srv.starts.length === 2, String(srv.starts.length)); + const up = srv.uploads[0]; + ok('render uploaded as a JPEG mockup named self-review-render.jpg, > 2000 bytes', + !!up && up.mime === 'image/jpeg' && up.kind === 'mockup' && up.name === 'self-review-render.jpg' && up.size > 2000, JSON.stringify(up)); + const s2 = srv.starts[1] ?? {}; + ok('second job message starts "Review the attached screenshot"', typeof s2.message === 'string' && s2.message.startsWith('Review the attached screenshot'), String(s2.message).slice(0, 60)); + ok('second job carries the upload id in mockup_ids', Array.isArray(s2.mockup_ids) && s2.mockup_ids.includes(up?.id), JSON.stringify(s2.mockup_ids)); + ok('second job has use_assets false', s2.use_assets === false, String(s2.use_assets)); + ok('render image deleted afterwards', srv.imageDeletes.includes(up?.id), JSON.stringify(srv.imageDeletes)); + await page.waitForTimeout(2500); + ok('no third job', srv.starts.length === 2, String(srv.starts.length)); + await modalShot(page, name, '08-self-review-done.png'); +}, { selfReview: true }); + +scenario('8b', 'self-review skipped when the upload fails', async ({ page, ok }) => { + srv.setUploadFail(500); + srv.setNextResult(pageResult('Kept first pass', { id: 'kp-h' })); + await openModal(page); + await sendMsg(page, 'Build it'); + await page.waitForSelector('[data-testid=ss-review-note]', { timeout: 45000 }); + const note = (await page.textContent('[data-testid=ss-review-note]')) ?? ''; + ok('non-blocking "Self-review skipped" note', /Self-review skipped/.test(note), note); + ok('first job content still applied', (await canvasText(page)).includes('Kept first pass')); + ok('no second job', srv.starts.length === 1, String(srv.starts.length)); + ok('no blocking error shown', (await page.locator('[data-testid=ss-error]').count()) === 0); +}, { selfReview: true }); + +// ------------------------------------------------------------------ run + +async function run(name, launcher, exe) { + const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); + for (const sc of scenarios) { + if (process.env.SITESMITH_SCENARIO && !process.env.SITESMITH_SCENARIO.split(',').includes(sc.id)) continue; + srv.reset(); + const label = `${sc.id} ${sc.title}`; + let page; + try { + page = await newPage(browser, name, sc.opts); + await sc.fn({ page, name, ok: (l, c, d) => log(name, label, !!c, l, d) }); + await page.waitForTimeout(200); + const bad = await page.evaluate(() => window.__bad); + log(name, label, page.__errors.length === 0, 'no uncaught page errors', page.__errors.join(' | ')); + log(name, label, bad.length === 0, 'no NetworkError / Failed to fetch text in the modal', JSON.stringify(bad)); + const ext = page.__external.filter((u) => !/\.css(\?|$)|fonts\.(googleapis|gstatic)/.test(u)); + if (ext.length) console.log(`[${name}] note ${label}: ${ext.length} external request(s) aborted locally`); + } catch (e) { + log(name, label, false, 'scenario completed', String(e?.message ?? e).split('\n').slice(0, 3).join(' ')); + if (page && name === 'firefox') await page.screenshot({ path: path.join(shotsDir, `fail-${sc.id}.png`) }).catch(() => {}); + } finally { + await page?.__ctx.close().catch(() => {}); + } + } + await browser.close(); +} + +const only = process.env.SITESMITH_ONLY; +if (only !== 'firefox') await run('chromium', chromium, process.env.CHROMIUM_PATH); +if (only !== 'chromium') await run('firefox', firefox, process.env.FIREFOX_PATH); +await srv.close(); +console.log(failures ? `\n${failures} FAILURE(S)` : '\nALL PASS'); +process.exit(failures ? 1 : 0); diff --git a/craft/src/hooks/useSitesmith.test.ts b/craft/src/hooks/useSitesmith.test.ts index daf0645..07d4196 100644 --- a/craft/src/hooks/useSitesmith.test.ts +++ b/craft/src/hooks/useSitesmith.test.ts @@ -401,3 +401,42 @@ describe('useSitesmith capabilities and wrappers', () => { expect(await api.uploadRefImage(file, 'mockup')).toMatchObject({ ok: false, message: expect.stringMatching(/413/) }); }); }); + +describe('request payload: design, assets and attachments', () => { + test('start carries design, mockup ids, reference ids, use_assets and asset_filenames exactly', async () => { + const bodies: any[] = []; + override = (url, init) => { + if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 200, json: async () => ({ ok: true, job_id: 'j9', status: 'running', phase: 'queued' }) }; } + if (url.includes('action=poll')) return { status: 200, json: async () => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response: { type: 'ask', question: 'q' } } }) }; + return undefined; + }; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + try { + let p!: Promise; + await act(async () => { + p = api.send('hi', 'canvas', undefined, { + design: { primaryColor: '#123456', brandColors: [{ name: 'Teal', hex: '#0d9488' }] }, + mockupIds: ['a'.repeat(32)], referenceIds: [3, 4], useAssets: true, assetFilenames: ['x.png'], + }); + await vi.advanceTimersByTimeAsync(1200); + await p; + }); + } finally { vi.useRealTimers(); } + expect(bodies).toHaveLength(1); + expect(bodies[0]).toMatchObject({ + site_id: 7, message: 'hi', canvas_summary: 'canvas', + design: { primaryColor: '#123456', brandColors: [{ name: 'Teal', hex: '#0d9488' }] }, + mockup_ids: ['a'.repeat(32)], reference_ids: [3, 4], use_assets: true, asset_filenames: ['x.png'], + }); + }); + + test('an empty design is not sent', async () => { + const bodies: any[] = []; + override = (url, init) => { + if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 400, json: async () => ({ ok: false, error: 'unknown_action' }) }; } + return undefined; + }; + await act(async () => { await api.send('hi', 'c', undefined, { design: {} }); }); + expect(sends[0].design).toBeUndefined(); + }); +}); diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index 5542b94..cf084f2 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -61,6 +61,10 @@ export interface SitesmithSendOpts { mockupIds?: string[]; referenceIds?: number[]; useAssets?: boolean; + /** Current site design (see utils/sitesmith-design.ts); read by the backend prompt builder. */ + design?: Record; + /** Uploaded assets Sitesmith may see (relative filenames). Omitted = all. Needs backend support; see report. */ + assetFilenames?: string[]; onProgress?: (phase: string) => void; signal?: AbortSignal; } @@ -163,6 +167,7 @@ export function useSitesmith(siteId: number) { const [error, setError] = useState(null); const [activeJob, setActiveJobState] = useState(null); const [capabilities, setCapabilities] = useState(null); + const [capabilitiesFailed, setCapabilitiesFailed] = useState(false); useEffect(() => { alive.current = true; @@ -185,6 +190,9 @@ export function useSitesmith(siteId: number) { setCapabilities({ vision: p.body.vision, features: p.body.features, limits: p.body.limits, } as SitesmithCapabilities); + } else { + // Older backend without `capabilities`: image features are unavailable, text generation is unaffected. + setCapabilitiesFailed(true); } }, [whpConfig]); @@ -332,6 +340,7 @@ export function useSitesmith(siteId: number) { if (target) body.target = target; if (profile) body.profile = profile; if (opts?.intent) body.intent = opts.intent; + if (opts?.design && Object.keys(opts.design).length) body.design = opts.design; let result: SendResult; let usedStart = false; @@ -340,6 +349,7 @@ export function useSitesmith(siteId: number) { if (opts?.mockupIds?.length) startBody.mockup_ids = opts.mockupIds; if (opts?.referenceIds?.length) startBody.reference_ids = opts.referenceIds; if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets; + if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames; const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, { method: 'POST', credentials: 'include', @@ -477,7 +487,7 @@ export function useSitesmith(siteId: number) { return { summary, messages, loading, error, send, refreshEntitlement, clearHistory, - activeJob, resumeJob, cancel, capabilities, + activeJob, resumeJob, cancel, capabilities, capabilitiesFailed, listRefs, addReference, updateReference, deleteReference, uploadRefImage, deleteRefImage, refImageUrl, listAssets, }; diff --git a/craft/src/panels/sitesmith/AssetAwareness.tsx b/craft/src/panels/sitesmith/AssetAwareness.tsx new file mode 100644 index 0000000..182d834 --- /dev/null +++ b/craft/src/panels/sitesmith/AssetAwareness.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type { SitesmithAsset } from '../../hooks/useSitesmith'; +import { note, thumb, errBox } from './Section'; + +interface Props { + assets: SitesmithAsset[]; + loaded: boolean; + error?: string | null; + /** Filenames Sitesmith may see. */ + included: Set; + onToggle: (filename: string) => void; + onAll: (on: boolean) => void; + /** Public-safe display URL for a thumbnail. */ + thumbUrl: (a: SitesmithAsset) => string; + visionAvailable: boolean; + disabled?: boolean; +} + +/** Which uploaded images Sitesmith will see, with the captions it has, and a per-image toggle. */ +export const AssetAwareness: React.FC = ({ assets, loaded, error, included, onToggle, onAll, thumbUrl, visionAvailable, disabled }) => ( +
+

+ Sitesmith can use the images you uploaded to this site and place them on the page. Untick any you do not want it to use. + {visionAvailable + ? ' It looks at each picture (a small copy) and writes a short description the first time.' + : ' Image viewing is not available on this server, so Sitesmith works from file names and sizes only.'} +

+ {error &&
{error}
} + {!loaded && !error &&

Loading your images…

} + {loaded && !error && assets.length === 0 &&

You have not uploaded any images yet. Add some in the Assets tab and Sitesmith will be able to use them.

} + {assets.length > 0 && ( + <> +
+ + + {included.size} of {assets.length} included +
+
    + {assets.map((a) => ( +
  • + onToggle(a.filename)} aria-label={`Let Sitesmith use ${a.filename}`} /> + +
    +
    {a.filename}{a.width && a.height ? · {a.width}×{a.height} : null}
    +
    {a.caption || a.alt || 'No description yet'}
    +
    +
  • + ))} +
+ + )} +
+); diff --git a/craft/src/panels/sitesmith/AttachmentTray.tsx b/craft/src/panels/sitesmith/AttachmentTray.tsx new file mode 100644 index 0000000..c25689a --- /dev/null +++ b/craft/src/panels/sitesmith/AttachmentTray.tsx @@ -0,0 +1,113 @@ +import React, { useRef, useState } from 'react'; +import type { SitesmithRefImage } from '../../hooks/useSitesmith'; +import { formatBytes } from '../../utils/sitesmith-attachments'; +import { btn, btnDanger, errBox, note, thumb, warnBox } from './Section'; + +interface Props { + /** Mockup images already stored for this site (from refs_list). */ + images: SitesmithRefImage[]; + selected: string[]; + onSelectedChange: (ids: string[]) => void; + /** Total image budget for one send (mockups + reference screenshots). */ + maxAttach: number; + /** Images already counted by selected "Sites I like" entries. */ + referenceImageCount: number; + imageUrl: (id: string) => string; + onUpload: (file: File) => Promise<{ ok: true; image?: SitesmithRefImage } | { ok: false; message: string }>; + onRemove: (id: string) => Promise<{ ok: true } | { ok: false; message: string }>; + /** Image features off: why (vision unavailable). Text-only generation is unaffected. */ + unavailableReason?: string | null; + disabled?: boolean; + /** Called with the message when a file is rejected or an upload fails. */ +} + +/** Mockup picker: drag and drop or file picker, thumbnails, remove. Image names are rendered as text nodes only. */ +export const AttachmentTray: React.FC = ({ + images, selected, onSelectedChange, maxAttach, referenceImageCount, imageUrl, onUpload, onRemove, unavailableReason, disabled, +}) => { + const input = useRef(null); + const [drag, setDrag] = useState(false); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const mockups = images.filter((i) => i.kind === 'mockup'); + const off = !!unavailableReason || !!disabled; + const used = selected.length + referenceImageCount; + + const addFiles = async (files: FileList | File[] | null) => { + if (!files || off) return; + setErr(null); + setBusy(true); + try { + for (const f of Array.from(files)) { + if (used + 1 > maxAttach) { setErr(`You can attach up to ${maxAttach} images to one request.`); break; } + const r = await onUpload(f); + if (!r.ok) { setErr(r.message); break; } + if (r.image) onSelectedChange([...selected, r.image.id]); + } + } finally { setBusy(false); } + }; + + const toggle = (id: string) => { + if (selected.includes(id)) onSelectedChange(selected.filter((x) => x !== id)); + else if (used < maxAttach) onSelectedChange([...selected, id]); + else setErr(`You can attach up to ${maxAttach} images to one request.`); + }; + + const remove = async (id: string) => { + setErr(null); + const r = await onRemove(id); + if (!r.ok) { setErr(r.message); return; } + onSelectedChange(selected.filter((x) => x !== id)); + }; + + return ( +
+

+ Attach a picture of the page you want (a sketch, a screenshot, a design export). Sitesmith builds toward it. + The image is kept privately for Sitesmith; it is never published or added to your assets. +

+ {unavailableReason &&
{unavailableReason}
} +
{ if (off) return; e.preventDefault(); setDrag(true); }} + onDragLeave={() => setDrag(false)} + onDrop={(e) => { e.preventDefault(); setDrag(false); void addFiles(e.dataTransfer?.files ?? null); }} + style={{ + border: `1px dashed ${drag ? '#a78bfa' : '#3f3f46'}`, borderRadius: 8, padding: 12, textAlign: 'center', + background: drag ? 'rgba(124,58,237,0.12)' : 'transparent', opacity: off ? 0.5 : 1, + }} + > + + or drop it here (JPEG, PNG, WebP or GIF, up to 8 MB) + { void addFiles(e.target.files); e.target.value = ''; }} + /> +
+ {err &&
{err}
} + {mockups.length > 0 && ( +
    + {mockups.map((im) => { + const on = selected.includes(im.id); + return ( +
  • + +
    {im.width && im.height ? `${im.width}×${im.height} · ` : ''}{formatBytes(im.size)}
    + +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/craft/src/panels/sitesmith/JobStatus.tsx b/craft/src/panels/sitesmith/JobStatus.tsx new file mode 100644 index 0000000..cede96a --- /dev/null +++ b/craft/src/panels/sitesmith/JobStatus.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { btn } from './Section'; + +export const PHASE_LABEL: Record = { + queued: 'Waiting to start', + preparing: 'Preparing your request', + captioning: 'Looking at your images', + generating: 'Designing your page', + retrying: 'Trying again to get a valid result', + finalizing: 'Finishing up', + done: 'Done', + review: 'Reviewing the finished page', +}; + +const ORDER = ['queued', 'preparing', 'captioning', 'generating', 'finalizing']; + +interface Props { + phase: string; + /** Seconds since the job began, if the server knows (a resumed job); else counted from mount. */ + startedAgoS?: number; + onCancel?: () => void; + cancelling?: boolean; + resumed?: boolean; +} + +/** Live status line for a running Sitesmith job: phase, elapsed time, steps, and a Cancel button. */ +export const JobStatus: React.FC = ({ phase, startedAgoS = 0, onCancel, cancelling, resumed }) => { + const [t0] = useState(() => Date.now() - startedAgoS * 1000); + const [elapsed, setElapsed] = useState(startedAgoS); + useEffect(() => { + const id = window.setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 1000); + return () => window.clearInterval(id); + }, [t0]); + const label = cancelling ? 'Cancelling…' : (PHASE_LABEL[phase] ?? 'Working'); + const idx = ORDER.indexOf(phase); + return ( +
+
+ + {label}… + {elapsed}s + + {onCancel && } +
+ {idx >= 0 && ( +
+ {ORDER.map((p, i) => )} +
+ )} +
+ {resumed ? 'Picked up a request that was already running. ' : ''} + You can close this window: Sitesmith keeps working and the result will be waiting when you come back. +
+
+ ); +}; diff --git a/craft/src/panels/sitesmith/Section.tsx b/craft/src/panels/sitesmith/Section.tsx new file mode 100644 index 0000000..02687f2 --- /dev/null +++ b/craft/src/panels/sitesmith/Section.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +const sectionStyle: React.CSSProperties = { border: '1px solid #27272a', borderRadius: 8, marginBottom: 8, background: '#13131c' }; +const headStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, width: '100%', background: 'transparent', border: 'none', color: '#e4e4e7', padding: '8px 12px', cursor: 'pointer', fontSize: 13, fontWeight: 600, textAlign: 'left' }; +const bodyStyle: React.CSSProperties = { padding: '4px 12px 12px', fontSize: 13, color: '#d4d4d8' }; + +/** Collapsible block used by the Sitesmith context area. */ +export const Section: React.FC<{ + id: string; title: string; icon?: string; badge?: string; open: boolean; onToggle: () => void; children: React.ReactNode; +}> = ({ id, title, icon, badge, open, onToggle, children }) => ( +
+ + {open &&
{children}
} +
+); + +export const note: React.CSSProperties = { fontSize: 12, color: '#a1a1aa', lineHeight: 1.5, margin: '4px 0 8px' }; +export const warnBox: React.CSSProperties = { background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.4)', color: '#fde68a', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' }; +export const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '6px 10px', borderRadius: 6, fontSize: 12, margin: '6px 0' }; +export const btn: React.CSSProperties = { background: 'transparent', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' }; +export const btnPrimary: React.CSSProperties = { background: '#7c3aed', color: '#fff', border: '1px solid #7c3aed', borderRadius: 4, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }; +export const btnDanger: React.CSSProperties = { background: 'transparent', color: '#fca5a5', border: '1px solid #7f1d1d', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer' }; +export const inputStyle: React.CSSProperties = { width: '100%', boxSizing: 'border-box', background: '#0f0f17', color: '#e5e7eb', border: '1px solid #3f3f46', borderRadius: 6, padding: '6px 8px', fontSize: 13 }; +export const thumb: React.CSSProperties = { width: 64, height: 64, objectFit: 'cover', borderRadius: 6, border: '1px solid #3f3f46', background: '#0f0f17', display: 'block' }; diff --git a/craft/src/panels/sitesmith/SitesILikePanel.tsx b/craft/src/panels/sitesmith/SitesILikePanel.tsx new file mode 100644 index 0000000..7337910 --- /dev/null +++ b/craft/src/panels/sitesmith/SitesILikePanel.tsx @@ -0,0 +1,166 @@ +import React, { useRef, useState } from 'react'; +import type { SitesmithReference, SitesmithRefImage } from '../../hooks/useSitesmith'; +import { btn, btnDanger, btnPrimary, errBox, inputStyle, note, thumb, warnBox } from './Section'; +import type { Outcome } from './useSitesmithRefs'; + +interface Props { + references: SitesmithReference[]; + images: SitesmithRefImage[]; + selected: number[]; + onSelectedChange: (ids: number[]) => void; + imageUrl: (id: string) => string; + loadError?: string | null; + addRef: (url: string, likes: string, shots: string[]) => Promise; + updateRef: (id: number, url: string, likes: string, shots: string[]) => Promise; + deleteRef: (id: number) => Promise; + uploadShot: (f: File) => Promise; + removeImage: (id: string) => Promise; + maxRefs: number; + /** Image features unavailable (no vision): text-only entries still work. */ + imagesUnavailableReason?: string | null; + disabled?: boolean; +} + +const MAX_SHOTS = 3; + +interface FormState { id: number | null; url: string; likes: string; shots: string[]; added: string[] } +const EMPTY: FormState = { id: null, url: '', likes: '', shots: [], added: [] }; + +/** "Sites I like": a per-site list of label + notes + optional screenshots. The address is a label and is never fetched. */ +export const SitesILikePanel: React.FC = ({ + references, images, selected, onSelectedChange, imageUrl, loadError, addRef, updateRef, deleteRef, uploadShot, removeImage, + maxRefs, imagesUnavailableReason, disabled, +}) => { + const [form, setForm] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmDel, setConfirmDel] = useState(null); + const fileRef = useRef(null); + const imgById = new Map(images.map((i) => [i.id, i])); + + const startAdd = () => { setErr(null); setForm({ ...EMPTY }); }; + const startEdit = (r: SitesmithReference) => { setErr(null); setForm({ id: r.id, url: r.url_label, likes: r.what_i_like, shots: [...r.screenshot_ids], added: [] }); }; + + const cancel = async () => { + // Screenshots uploaded for a form that was never saved would be orphans: drop them. + if (form) for (const id of form.added) await removeImage(id); + setForm(null); setErr(null); + }; + + const save = async () => { + if (!form) return; + setBusy(true); setErr(null); + try { + const r = form.id === null + ? await addRef(form.url, form.likes, form.shots) + : await updateRef(form.id, form.url, form.likes, form.shots); + if (!r.ok) { setErr(r.message); return; } + setForm(null); + } finally { setBusy(false); } + }; + + const addShot = async (files: FileList | null) => { + if (!files || !form) return; + setErr(null); setBusy(true); + let count = form.shots.length; + try { + for (const f of Array.from(files)) { + if (count >= MAX_SHOTS) { setErr(`Up to ${MAX_SHOTS} screenshots per site.`); break; } + const r = await uploadShot(f); + if (!r.ok) { setErr(r.message); break; } + const id = r.image!.id; + count++; + setForm((s) => (s ? { ...s, shots: [...s.shots, id], added: [...s.added, id] } : s)); + } + } finally { setBusy(false); } + }; + + const dropShot = async (id: string) => { + if (!form) return; + // Removing from a saved entry detaches it; an image added in this form is deleted outright. + if (form.added.includes(id)) await removeImage(id); + setForm((s) => (s ? { ...s, shots: s.shots.filter((x) => x !== id), added: s.added.filter((x) => x !== id) } : s)); + }; + + const del = async (id: number) => { + setErr(null); + const r = await deleteRef(id); + setConfirmDel(null); + if (!r.ok) { setErr(r.message); return; } + onSelectedChange(selected.filter((x) => x !== id)); + }; + + const toggle = (id: number) => onSelectedChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]); + + return ( +
+

+ List websites whose look you admire and say what you like about each. The address is only a label for you: + Sitesmith does not open, visit or fetch it. It works from your description and any screenshots you attach yourself. +

+ {loadError &&
{loadError}
} + {err && !form &&
{err}
} + + {references.length === 0 && !form &&

No sites yet.

} +
    + {references.map((r) => ( +
  • +
    + toggle(r.id)} aria-label={`Use ${r.url_label} for the next request`} style={{ marginTop: 3 }} /> +
    +
    {r.url_label}
    +
    {r.what_i_like}
    + {r.screenshot_ids.length > 0 && ( +
    + {r.screenshot_ids.map((sid) => ( + {`Screenshot + ))} +
    + )} +
    + + {confirmDel === r.id + ? + : } +
    +
  • + ))} +
+ + {form ? ( +
+ + setForm({ ...form, url: e.target.value })} /> + +