#!/usr/bin/env node /** * Real-browser check (Chromium AND Firefox, headless) that uploads Sitesmith places as `assets/` actually LOAD in the * editor canvas / Preview (a srcdoc iframe on the panel origin, like Canvas.tsx), and that publishing still yields only * `assets/`: * 1. control (the model's page as written): every upload is broken (naturalWidth 0) -- the bug this guards against; * 2. fixed (cleaner then toEditorAssetUrls, the apply order): every upload 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/` 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(''); 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: '' }); 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/ 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/', 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/`, 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);