sitesmith: show uploaded images in the editor canvas and Preview
Sitesmith writes uploads as assets/<file>, which the editor (panel origin) cannot load. After the repair + allow-list cleaner, apply now converts every real-upload reference to the same panel proxy URL the AssetPicker stores (publish rewrites it back to assets/<file>); canvas summaries sent to the model show assets/<file> again. Covers apply, self-review patches and regenerate-section. Adds unit, Craft integration and a Chromium+Firefox browser suite (test:browser:sitesmith-editor-assets) incl. the real PHP publish rewrite. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
1 parent
bf12520d01
commit
c6809293c4
13 files changed
+609
-6
No files matched your search
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* Used by scripts/e2e-sitesmith-editor-assets.mjs: reads {html,css} JSON on stdin and prints what the publish handler
|
||||
* (api/site-builder.php) sends on to generateCompiledHTML: the body and CSS after sb_rewrite_asset_urls(), then the compiled
|
||||
* document. Usage: php e2e-sitesmith-editor-assets-backend.php <backend repo dir>
|
||||
*/
|
||||
ob_start();
|
||||
define('SB_API_LIBRARY_ONLY', true);
|
||||
define('AUTH_USER', 'alice');
|
||||
define('HOME_DIR', '/docker/users/alice');
|
||||
require rtrim($argv[1], '/') . '/web-files/api/site-builder.php';
|
||||
ob_end_clean();
|
||||
$in = json_decode(stream_get_contents(STDIN), true);
|
||||
$body = sb_rewrite_asset_urls((string)$in['html']);
|
||||
$css = sb_rewrite_asset_urls((string)$in['css']);
|
||||
$U = 'https://example.com/';
|
||||
echo json_encode(['body' => $body, 'doc' => generateCompiledHTML('Home', $css, $body, '', [], '', [], $U, sb_canonical_url($U, 'index.html'))]);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Bundled (esbuild) by scripts/e2e-sitesmith-editor-assets.mjs: one Sitesmith response that places uploads as
|
||||
// `assets/<file>`, run through the apply-time order (cleaner, then the editor-URL conversion) and exported the way the
|
||||
// editor canvas / Preview and a publish do. Three variants: as the model wrote it (the bug), converted (the fix), and
|
||||
// the same page as a manual AssetPicker insertion (the reference the fix must equal).
|
||||
import { cleanAiResponse } from '../src/sitesmith/clean-ai-response';
|
||||
import { toEditorAssetUrls, editorProxyUrl } from '../src/sitesmith/editor-asset-urls';
|
||||
import { flattenTreeForCraft } from '../src/utils/craft-tree';
|
||||
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
|
||||
import { DEFAULT_SITE_DESIGN } from '../src/state/SiteDesignContext';
|
||||
import type { SitesmithResponse } from '../src/types/sitesmith';
|
||||
|
||||
export const SITE_ID = 7;
|
||||
/** Real upload names. Upload() sanitises to [A-Za-z0-9_.-]; the other two arrive via FTP / stock import. */
|
||||
export const FILES = ['1700000000_aa11bb22_hero.png', '1700000001_bb22cc33_logo+v2.png', 'my photo.png', 'café.png'];
|
||||
export const MISSING = 'assets/does-not-exist.png';
|
||||
export const ASSETS = FILES.map((f) => ({ filename: f, relUrl: `assets/${f}`, url: editorProxyUrl(SITE_ID, f), width: 800, height: 600 }));
|
||||
// what the model writes: relUrl verbatim, but the cleaner only accepts [A-Za-z0-9._%~+-], so a space / unicode name arrives %-encoded
|
||||
const rel = (i: number) => 'assets/' + FILES[i].split('').map((c) => (/[A-Za-z0-9._~+-]/.test(c) ? c : encodeURIComponent(c))).join('');
|
||||
const n = (type: string, props: Record<string, unknown>, nodes?: any[]) => ({ type: { resolvedName: type }, props, ...(nodes ? { nodes } : {}) });
|
||||
|
||||
function page(src: (i: number) => string): SitesmithResponse {
|
||||
return { type: 'replace', scope: 'page', message: 'm', pages: [{ name: 'Home', tree: n('Container', { node_id: 'ROOT', tag: 'div' }, [
|
||||
n('Navbar', { node_id: 'nav', logoType: 'image', logoImage: src(1), logoUrl: '/', links: [{ text: 'Home', href: '/' }] }),
|
||||
n('BackgroundSection', { node_id: 'bg', bgImage: src(0), overlayOpacity: 0.2 }, [n('Heading', { node_id: 'h', text: 'Hello', level: 'h1' })]),
|
||||
n('ImageBlock', { node_id: 'i1', src: src(0), alt: 'plain' }),
|
||||
n('ImageBlock', { node_id: 'i2', src: src(1), alt: 'plus' }),
|
||||
n('ImageBlock', { node_id: 'i3', src: src(2), alt: 'space' }),
|
||||
n('ImageBlock', { node_id: 'i4', src: src(3), alt: 'unicode' }),
|
||||
n('Container', { node_id: 'c', tag: 'div', style: { minHeight: '60px', backgroundImage: `url(${src(0)})` } }),
|
||||
n('HtmlBlock', { node_id: 'html', code: `<img id="rich" src="${src(0)}" alt="rich">` }),
|
||||
n('ImageBlock', { node_id: 'i5', src: MISSING, alt: 'missing' }),
|
||||
]) }] };
|
||||
}
|
||||
|
||||
type Variant = 'bug' | 'fix' | 'manual';
|
||||
function tree(v: Variant) {
|
||||
// manual: the proxy URL is what the picker stores (no cleaner in that path); bug / fix: the model's `assets/<file>`, cleaned
|
||||
let resp: SitesmithResponse = v === 'manual' ? page((i) => ASSETS[i].url) : cleanAiResponse(page(rel));
|
||||
if (v === 'fix') resp = toEditorAssetUrls(resp, ASSETS, { siteId: SITE_ID }).resp;
|
||||
if (resp.type !== 'replace') throw new Error('unexpected');
|
||||
return JSON.stringify(flattenTreeForCraft(resp.pages[0].tree).nodes);
|
||||
}
|
||||
export function body(v: Variant): string { return exportBodyHtml(tree(v)).html; }
|
||||
/** The editor Preview / canvas document. */
|
||||
export function editorDoc(v: Variant): string {
|
||||
const state = tree(v);
|
||||
return exportComposedPreviewHtml(state, exportBodyHtml(state).html, { title: 'Home', includeFonts: false, design: DEFAULT_SITE_DESIGN });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-browser check (Chromium AND Firefox, headless) that uploads Sitesmith places as `assets/<file>` actually LOAD in the
|
||||
* editor canvas / Preview (a srcdoc iframe on the panel origin, like Canvas.tsx), and that publishing still yields only
|
||||
* `assets/<file>`:
|
||||
* 1. control (the model's page as written): every upload <img> is broken (naturalWidth 0) -- the bug this guards against;
|
||||
* 2. fixed (cleaner then toEditorAssetUrls, the apply order): every upload <img> loads from the stub proxy for THIS site,
|
||||
* the background and CSS url() uploads are requested, a file that does not exist is left alone;
|
||||
* 3. publish: the fixed page run through the REAL backend sb_rewrite_asset_urls + generateCompiledHTML contains
|
||||
* `assets/<file>` only (no proxy URL, no site_id), is byte-identical to the same page inserted manually with the
|
||||
* AssetPicker, and the published document's images load from the site's own /assets/.
|
||||
* Nothing leaves the machine (page.route answers or aborts every request).
|
||||
* npm run test:browser:sitesmith-editor-assets Env: WHP_BACKEND_DIR (a whp worktree root), CHROMIUM_PATH / FIREFOX_PATH
|
||||
*/
|
||||
import { chromium, firefox } from '@playwright/test';
|
||||
import { build as esbuild } from 'esbuild';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BACKEND_DIR = process.env.WHP_BACKEND_DIR || '/workspace/whp-wt-editor-asset-urls';
|
||||
const out = await esbuild({
|
||||
entryPoints: [path.join(root, 'scripts/e2e-sitesmith-editor-assets-entry.ts')],
|
||||
bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error',
|
||||
define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' },
|
||||
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
|
||||
});
|
||||
const tmp = path.join(os.tmpdir(), `wsb-ss-editor-assets-${process.pid}.mjs`);
|
||||
fs.writeFileSync(tmp, out.outputFiles[0].text);
|
||||
// HtmlBlock sanitising (DOMPurify) needs a DOM; a bare jsdom window is enough for the exporter. Set before the bundle loads.
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||
globalThis.window = dom.window; globalThis.document = dom.window.document;
|
||||
const ent = await import(tmp);
|
||||
fs.rmSync(tmp, { force: true });
|
||||
|
||||
let pass = 0; let fail = 0; let skip = 0;
|
||||
const ok = (c, m, d = '') => { if (c) { pass++; console.log('PASS ', m); } else { fail++; console.log('FAIL ', m, d); } };
|
||||
|
||||
const PANEL = 'http://panel.test';
|
||||
const SITE = 'http://site.test';
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
|
||||
const known = new Set(ent.FILES);
|
||||
|
||||
/** Load `doc` inside a srcdoc iframe on the panel origin (as the canvas does); answer the proxy like api/assets-proxy. */
|
||||
async function loadEditor(browser, doc) {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const reqs = [];
|
||||
await page.route('**/*', async (route) => {
|
||||
const u = new URL(route.request().url());
|
||||
reqs.push(u.pathname + u.search);
|
||||
if (u.origin !== PANEL) return route.abort();
|
||||
if (u.pathname === '/site-builder/') return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><iframe id="f" style="width:900px;height:900px"></iframe>' });
|
||||
if (u.pathname === '/api/assets-proxy/') {
|
||||
// URLSearchParams decodes '+' as a space, exactly like PHP's $_GET
|
||||
const name = u.searchParams.get('filename');
|
||||
if (u.searchParams.get('site_id') === String(ent.SITE_ID) && known.has(name)) return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
|
||||
}
|
||||
return route.fulfill({ status: 404, body: '' });
|
||||
});
|
||||
await page.goto(PANEL + '/site-builder/', { waitUntil: 'load' });
|
||||
await page.evaluate((d) => { document.getElementById('f').srcdoc = d; }, doc);
|
||||
const frame = await (await page.waitForSelector('#f')).contentFrame();
|
||||
await frame.waitForSelector('img');
|
||||
await page.waitForTimeout(500);
|
||||
const imgs = await frame.evaluate(() => [...document.images].map((i) => ({ alt: i.alt, id: i.id, ok: i.complete && i.naturalWidth > 0 })));
|
||||
const bgs = await frame.evaluate(() => [...document.querySelectorAll('body *')].map((e) => getComputedStyle(e).backgroundImage).filter((b) => b && b !== 'none'));
|
||||
await ctx.close();
|
||||
return { imgs, reqs, bgs };
|
||||
}
|
||||
|
||||
/** Load the PUBLISHED document at the site root; /assets/<file> is served by decoded path. */
|
||||
async function loadPublished(browser, doc) {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.route('**/*', async (route) => {
|
||||
const u = new URL(route.request().url());
|
||||
if (u.origin !== SITE) return route.abort();
|
||||
if (u.pathname === '/') return route.fulfill({ status: 200, contentType: 'text/html', body: doc });
|
||||
if (u.pathname.startsWith('/assets/') && known.has(decodeURIComponent(u.pathname.slice(8)))) return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
|
||||
return route.fulfill({ status: 404, body: '' });
|
||||
});
|
||||
await page.goto(SITE + '/', { waitUntil: 'load' });
|
||||
await page.waitForTimeout(500);
|
||||
const imgs = await page.evaluate(() => [...document.images].map((i) => ({ alt: i.alt, ok: i.complete && i.naturalWidth > 0 })));
|
||||
await ctx.close();
|
||||
return imgs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- publish through the real backend
|
||||
const haveBackend = fs.existsSync(path.join(BACKEND_DIR, 'web-files/api/site-builder.php'));
|
||||
function publish(variant) {
|
||||
const raw = execFileSync('php', [path.join(root, 'scripts/e2e-sitesmith-editor-assets-backend.php'), BACKEND_DIR], { input: JSON.stringify({ html: ent.body(variant), css: '' }), maxBuffer: 1 << 26 }).toString();
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
const WANT_REL = ['assets/1700000000_aa11bb22_hero.png', 'assets/1700000001_bb22cc33_logo%2Bv2.png', 'assets/my%20photo.png', 'assets/caf%C3%A9.png'];
|
||||
|
||||
// static checks first (no browser needed)
|
||||
const fixedBody = ent.body('fix');
|
||||
ok(!/src="assets\//.test(fixedBody) || /does-not-exist/.test(fixedBody), 'fixed editor HTML has no upload left as a relative assets/ src');
|
||||
ok(fixedBody.includes('assets/does-not-exist.png'), 'a file that is not an upload is left as written (asset-repair owns it)');
|
||||
ok(ent.body('bug').includes('assets/1700000000_aa11bb22_hero.png'), 'control still carries the relative reference (the bug exists without the conversion)');
|
||||
|
||||
let pub = null;
|
||||
if (haveBackend) {
|
||||
pub = publish('fix');
|
||||
const man = publish('manual');
|
||||
ok(WANT_REL.every((r) => pub.body.includes(r)), 'published body references every upload as assets/<rawurlencoded file>', pub.body.match(/assets\/[^"')]*/g)?.join(' '));
|
||||
ok(!/assets-proxy|site_id|serve_asset/.test(pub.body) && !/assets-proxy|site_id|serve_asset/.test(pub.doc), 'no editor-only URL leaks into the published HTML (body or document)');
|
||||
ok(pub.body === man.body, 'published body is byte-identical to a manual AssetPicker insertion');
|
||||
ok(pub.doc === man.doc, 'published document is byte-identical to a manual AssetPicker insertion');
|
||||
ok(pub.body.includes('does-not-exist.png'), 'published page still has the non-upload reference untouched');
|
||||
} else {
|
||||
skip++; console.log(`SKIP published-output checks: no backend at WHP_BACKEND_DIR=${BACKEND_DIR}`);
|
||||
}
|
||||
|
||||
for (const [name, launcher, exe] of [['chromium', chromium, process.env.CHROMIUM_PATH], ['firefox', firefox, process.env.FIREFOX_PATH]]) {
|
||||
let browser;
|
||||
try { browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); }
|
||||
catch (e) { skip++; console.log(`SKIP [${name}] browser not available: ${String(e.message).split('\n')[0]}`); continue; }
|
||||
const control = await loadEditor(browser, ent.editorDoc('bug'));
|
||||
const upl = (r) => r.imgs.filter((i) => ['plain', 'plus', 'space', 'unicode', 'rich'].includes(i.alt));
|
||||
ok(upl(control).length === 5 && upl(control).every((i) => !i.ok), `[${name}] control (as the model wrote it): all 5 upload images are BROKEN in the editor`, JSON.stringify(control.imgs));
|
||||
const fixed = await loadEditor(browser, ent.editorDoc('fix'));
|
||||
ok(upl(fixed).length === 5 && upl(fixed).every((i) => i.ok), `[${name}] fixed: all 5 upload images (plain, + , space, unicode, HtmlBlock) load in the editor (naturalWidth>0)`, JSON.stringify(fixed.imgs));
|
||||
const reqs = fixed.reqs.filter((r) => r.startsWith('/api/assets-proxy/'));
|
||||
ok(reqs.length >= 4 && reqs.every((r) => r.includes('site_id=' + ent.SITE_ID)), `[${name}] fixed: the proxy is requested for the CURRENT site only`, JSON.stringify(reqs));
|
||||
const proxyBgs = fixed.bgs.filter((b) => b.includes('/api/assets-proxy/?site_id=' + ent.SITE_ID));
|
||||
ok(proxyBgs.length >= 2, `[${name}] fixed: BackgroundSection bgImage and a CSS url() background both resolve to the proxy`, JSON.stringify(fixed.bgs));
|
||||
ok(!control.bgs.some((b) => b.includes('assets-proxy')) && control.bgs.length >= 2, `[${name}] control: those backgrounds point at the broken relative path`, JSON.stringify(control.bgs));
|
||||
ok(!fixed.reqs.some((r) => r.startsWith('/site-builder/assets/') && !r.includes('does-not-exist')), `[${name}] fixed: nothing asks the panel for /site-builder/assets/<upload>`, JSON.stringify(fixed.reqs));
|
||||
if (pub) {
|
||||
const p = await loadPublished(browser, pub.doc);
|
||||
const u = p.filter((i) => ['plain', 'plus', 'space', 'unicode', 'rich'].includes(i.alt));
|
||||
ok(u.length === 5 && u.every((i) => i.ok), `[${name}] published document: every upload image loads from the site's own /assets/`, JSON.stringify(p));
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\n${pass} passed, ${fail} failed, ${skip} skipped`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in new issue
Block a user