Files
site-builder/craft/scripts/e2e-preview-assets.mjs
T

193 lines
14 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* Real-browser check (Chromium AND Firefox, headless) that uploaded images render in PREVIEW, the way the editor
* really opens it: a click opens a panel-origin tab (window.open('')), which holds an
* <iframe sandbox="allow-scripts allow-popups allow-forms" srcdoc> -- an OPAQUE (null) origin
* (utils/sandboxed-preview.ts). The asset stub is a real HTTP server sending the same headers as
* whp libs/SiteBuilderAssetServe.php, above all `Cross-Origin-Resource-Policy: same-site`.
* 1. control (origin/main behaviour, openSandboxedPreview on the exported HTML): every upload image is
* blocked by CORP (naturalWidth 0) -- the canary bug (ERR_BLOCKED_BY_RESPONSE.NotSameSite);
* 2. fixed (openPreviewWithInlinedAssets): the editor fetches this site's proxy assets and hands them over as
* data: URLs; every upload image, srcset, poster, favicon and background loads; a missing file, another
* site's file and an <a href> link keep their original URL; the frame is still null-origin;
* 3. measured: a blob: URL made by the editor window or by the Preview tab does NOT load in that frame
* (why data: is used);
* 4. publish: the same page through the REAL backend sb_rewrite_asset_urls + generateCompiledHTML holds
* `assets/<file>` only -- no data: URL, no proxy URL (Preview-only transformation).
* Nothing leaves the machine: the server listens on 127.0.0.1 and every other request is aborted.
* npm run test:browser:preview-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 http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
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';
const define = { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' };
// node side: the Preview document from the real exporter
const nodeOut = await esbuild({
entryPoints: [path.join(root, 'scripts/e2e-preview-assets-entry.ts')],
bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error', define,
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
});
const tmp = path.join(os.tmpdir(), `wsb-preview-assets-${process.pid}.mjs`);
fs.writeFileSync(tmp, nodeOut.outputFiles[0].text);
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 });
// browser side: the real Preview openers
const browserJs = (await esbuild({
stdin: {
contents: "import { openSandboxedPreview } from './src/utils/sandboxed-preview';\n"
+ "import { openPreviewWithInlinedAssets } from './src/utils/preview-assets';\n"
+ 'window.WSB = { openSandboxedPreview, openPreviewWithInlinedAssets };',
resolveDir: root, loader: 'ts',
},
bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error', define,
})).outputFiles[0].text;
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); } };
// An 8x8 PNG: a 1x1 image picked as a srcset 2x candidate has a density-corrected naturalWidth of 0.5 -> 0.
const PNG = (() => {
const crcT = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
const chunk = (t, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]); };
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(8, 0); ihdr.writeUInt32BE(8, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.concat(Array.from({ length: 8 }, () => Buffer.from([0, ...Array(24).fill(200)])));
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
})();
const ASSET_HEADERS = { // libs/SiteBuilderAssetServe.php sb_serve_asset()
'Content-Type': 'image/png', 'Content-Length': String(PNG.length), 'X-Content-Type-Options': 'nosniff',
'Cross-Origin-Resource-Policy': 'same-site', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
'Cache-Control': 'public, max-age=86400',
};
const srv = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/site-builder/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
return res.end('<!doctype html><title>editor</title><script src="/wsb.js"></script><button id="old">old</button><button id="new">new</button>'
+ '<script>const go=(m)=>{const win=window.open("","_blank");setTimeout(()=>{m==="old"?WSB.openSandboxedPreview(window.__html,{title:"Preview",win})'
+ ':WSB.openPreviewWithInlinedAssets(window.__html,{title:"Preview",win,origin:location.origin,siteId:7})},50)};'
+ 'document.getElementById("old").onclick=()=>go("old");document.getElementById("new").onclick=()=>go("new");</script>');
}
if (u.pathname === '/wsb.js') { res.writeHead(200, { 'Content-Type': 'text/javascript' }); return res.end(browserJs); }
if (u.pathname === '/api/assets-proxy/') {
const site = u.searchParams.get('site_id'); const f = u.searchParams.get('filename');
if ((site === String(ent.SITE_ID) && ent.FILES.includes(f)) || (site === '8' && f === ent.OTHER_SITE_FILE)) { res.writeHead(200, ASSET_HEADERS); return res.end(PNG); }
}
res.writeHead(404); res.end();
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const ORIGIN = `http://127.0.0.1:${srv.address().port}`;
const html = ent.previewHtml(ORIGIN);
// ------------------------------------------------ static: the inliner only swaps this site's proxy URLs
ok(html.includes(`src="${ORIGIN}/api/assets-proxy/?site_id=7&amp;filename=`), 'control document carries absolute same-origin proxy URLs (as origin/main builds it)');
const hits = ent.collectPreviewAssetUrls(html, { origin: ORIGIN, siteId: ent.SITE_ID });
const names = [...new Set(hits.map((h) => new URL(h.key).searchParams.get('filename')))];
ok(ent.FILES.every((f) => names.includes(f)), 'collector finds every upload: img, srcset, poster, logo, favicon, backgrounds', JSON.stringify(names));
ok(!names.includes(ent.OTHER_SITE_FILE), "collector leaves another site's proxy URL alone");
// ------------------------------------------------ publish through the real backend (Preview-only transformation)
if (fs.existsSync(path.join(BACKEND_DIR, 'web-files/api/site-builder.php'))) {
const pub = JSON.parse(execFileSync('php', [path.join(root, 'scripts/e2e-sitesmith-editor-assets-backend.php'), BACKEND_DIR],
{ input: JSON.stringify({ html: ent.publishBody(), css: '' }), maxBuffer: 1 << 26 }).toString());
ok(!/data:image|assets-proxy|serve_asset/.test(pub.body + pub.doc), 'published body + document hold no data: URL and no proxy URL');
ok(['assets/1700000000_aa11bb22_hero.png', 'assets/1700000001_bb22cc33_logo%2Bv2.png', 'assets/my%20photo.png', 'assets/caf%C3%A9.png', 'assets/poster.png'].every((r) => pub.body.includes(r)),
'published body references the uploads as assets/<file>', pub.body.match(/assets\/[^"')\s]*/g)?.join(' '));
} else { skip++; console.log(`SKIP published-output checks: no backend at WHP_BACKEND_DIR=${BACKEND_DIR}`); }
/** Open Preview with the given opener via a real click and read what the sandboxed frame shows. */
async function preview(browser, mode) {
const ctx = await browser.newContext();
await ctx.route('**/*', (route) => (new URL(route.request().url()).origin === ORIGIN || route.request().url().startsWith('data:') ? route.continue() : route.abort()));
const page = await ctx.newPage();
await page.goto(ORIGIN + '/site-builder/');
await page.evaluate((h) => { window.__html = h; }, html);
const [popup] = await Promise.all([page.waitForEvent('popup'), page.click('#' + mode)]);
const fh = await popup.waitForSelector('iframe', { timeout: 15000 });
const frame = await fh.contentFrame();
await frame.waitForSelector('img#rich', { timeout: 15000 });
await frame.waitForFunction(() => [...document.images].every((i) => i.complete), null, { timeout: 15000 }).catch(() => {});
await popup.waitForTimeout(300);
const r = await frame.evaluate(async () => {
const imgs = Object.fromEntries([...document.images].map((i) => [i.id || i.alt, { w: i.naturalWidth, src: i.currentSrc || i.src }]));
const bgs = [...document.querySelectorAll('body *')].map((e) => getComputedStyle(e).backgroundImage).filter((b) => b && b !== 'none' && b.includes('url('));
const bgLoads = await Promise.all(bgs.map((b) => new Promise((res) => { const m = /url\("?(.*?)"?\)$/.exec(b); const i = new Image(); i.onload = () => res(i.naturalWidth > 0); i.onerror = () => res(false); i.src = m ? m[1] : ''; })));
return {
origin: String(self.origin), imgs, bgs: bgs.map((b) => b.slice(0, 60)), bgLoads,
poster: document.getElementById('vid')?.getAttribute('poster')?.slice(0, 30),
link: document.getElementById('lnk')?.getAttribute('href'),
icon: document.querySelector('link[rel="icon"]')?.getAttribute('href')?.slice(0, 30),
};
});
const sandbox = await fh.getAttribute('sandbox');
await ctx.close();
return { ...r, sandbox };
}
/** Blob URLs (editor window / Preview tab) inside the same kind of null-origin srcdoc frame: measured, not assumed. */
async function blobProbe(browser) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(ORIGIN + '/site-builder/');
const res = await page.evaluate(async () => {
const win = window.open('', '_blank');
const bytes = await (await fetch('/api/assets-proxy/?site_id=7&filename=fav.png')).arrayBuffer();
const editorBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
const tabBlob = win.URL.createObjectURL(new win.Blob([bytes], { type: 'image/png' }));
const data = 'data:image/png;base64,' + btoa(String.fromCharCode(...new Uint8Array(bytes)));
return await new Promise((resolve) => {
win.addEventListener('message', (e) => resolve(e.data));
const f = win.document.createElement('iframe');
f.setAttribute('sandbox', 'allow-scripts allow-popups allow-forms');
f.srcdoc = `<img id="editorBlob" src="${editorBlob}"><img id="tabBlob" src="${tabBlob}"><img id="data" src="${data}">`
+ '<script>onload=()=>parent.postMessage(Object.fromEntries([...document.images].map(i=>[i.id,i.naturalWidth])),"*")<\/script>';
win.document.body.appendChild(f);
setTimeout(() => resolve(null), 8000);
});
});
await ctx.close();
return res;
}
const UPLOADS = ['plain', 'plus', 'space', 'unicode', 'rich', 'srcset', 'Logo'];
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 up = (r) => Object.entries(r.imgs).filter(([k]) => UPLOADS.includes(k) || /logo/i.test(k));
const control = await preview(browser, 'old');
ok(control.origin === 'null' && control.sandbox === 'allow-scripts allow-popups allow-forms', `[${name}] control: Preview frame is the sandboxed null-origin srcdoc`, `${control.origin} ${control.sandbox}`);
ok(up(control).length >= 6 && up(control).every(([, v]) => v.w === 0), `[${name}] control (origin/main): every upload image is BLOCKED by CORP same-site (naturalWidth 0)`, JSON.stringify(control.imgs));
ok(control.bgLoads.length >= 2 && control.bgLoads.every((b) => !b), `[${name}] control: the proxy backgrounds do not load either`, JSON.stringify(control.bgs));
const fixed = await preview(browser, 'new');
ok(fixed.origin === 'null' && fixed.sandbox === 'allow-scripts allow-popups allow-forms', `[${name}] fixed: the frame is STILL the sandboxed null-origin srcdoc (sandbox unchanged)`, `${fixed.origin} ${fixed.sandbox}`);
ok(up(fixed).length >= 6 && up(fixed).every(([, v]) => v.w > 0 && v.src.startsWith('data:image/png;base64,')), `[${name}] fixed: every upload image (plain, +, space, unicode, HtmlBlock, srcset, logo) loads from a data: URL`, JSON.stringify(Object.fromEntries(up(fixed).map(([k, v]) => [k, [v.w, v.src.slice(0, 22)]]))));
ok(fixed.bgs.length >= 2 && fixed.bgs.every((b) => b.startsWith('url("data:image/png')) && fixed.bgLoads.every(Boolean), `[${name}] fixed: BackgroundSection and CSS url() backgrounds are data: and load`, JSON.stringify(fixed.bgs));
ok(fixed.poster?.startsWith('data:image/png') && fixed.icon?.startsWith('data:image/png'), `[${name}] fixed: video poster and favicon are inlined`, `${fixed.poster} ${fixed.icon}`);
ok(fixed.imgs.missing?.w === 0 && fixed.imgs.missing.src.includes('/api/assets-proxy/?site_id=7&filename=does-not-exist.png'), `[${name}] fixed: a missing file keeps its proxy URL (no worse than before)`, JSON.stringify(fixed.imgs.missing));
ok(fixed.imgs.other?.src.includes('site_id=8') && fixed.imgs.other.w === 0, `[${name}] fixed: another site's proxy URL is not fetched or inlined`, JSON.stringify(fixed.imgs.other));
ok(fixed.link?.startsWith(ORIGIN + '/api/assets-proxy/') || fixed.link?.startsWith('/api/assets-proxy/'), `[${name}] fixed: an <a href> to an upload stays a link`, fixed.link);
const probe = await blobProbe(browser);
ok(probe && probe.editorBlob === 0 && probe.tabBlob === 0 && probe.data > 0, `[${name}] measured: blob: from the editor window and from the Preview tab do NOT load in the null-origin frame; data: does`, JSON.stringify(probe));
await browser.close();
}
srv.close();
console.log(`\n${pass} passed, ${fail} failed, ${skip} skipped`);
process.exit(fail ? 1 : 0);