mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
Effect Controls: Lumetri RGB and hue/saturation curve graphs, Colorama's palette wheel, Glow's colour map strip and Arbitrary Map curves, Reshape correspondence buttons and viewer handles (all prop.set, one undo step per gesture, automation ids). Preview panel scrolls; Composition/Timeline tabs get close, label swatch and viewer lock; Effect Controls tab the layer swatch; dropdowns elide long text. Fix the browser start (no default disk cache folder on wasm32). Browser smoke test covers frame workers, waiting renders and the Media Browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
363 lines
20 KiB
JavaScript
363 lines
20 KiB
JavaScript
// Browser smoke test of the EffectCraft web app over the Chrome DevTools Protocol.
|
|
// No npm dependencies (Node ≥ 22: global WebSocket and fetch).
|
|
//
|
|
// cargo xtask web --serve 8765 & # build + serve <target>/web/dist
|
|
// node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out /tmp/ec-web
|
|
//
|
|
// Steps: load (timing), the GPU path (WebGPU → GPU compositor, viewer frames on the GPU), demo
|
|
// comp in the viewer (screenshot, viewer pixels), `render.frame` through `window.effectcraft`,
|
|
// Render Queue GIF and PNG-sequence (.zip) exports (downloads), a background Render Queue job in
|
|
// a Web Worker while the page stays responsive (event-loop gaps measured), audio (the
|
|
// AudioContext starts on a user gesture; preview playback feeds it), File ▸ Save As (download),
|
|
// re-opening the saved project through `effectcraft.addFile`, persistence across a reload (the
|
|
// session, imported media, settings, a project saved to browser storage, Open Recent) and an
|
|
// offline reload through the service worker. Prints a JSON report; exits non-zero on failure.
|
|
// `--headed` shows the browser.
|
|
import { spawn } from "node:child_process";
|
|
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const arg = (k, d) => {
|
|
const i = process.argv.indexOf(`--${k}`);
|
|
return i > 0 ? process.argv[i + 1] : d;
|
|
};
|
|
const url = arg("url", "http://127.0.0.1:8765/");
|
|
const out = arg("out", join(tmpdir(), "effectcraft-web-smoke"));
|
|
const chrome = arg("chrome", process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "google-chrome");
|
|
const port = Number(arg("port", "9334"));
|
|
const headless = !process.argv.includes("--headed");
|
|
mkdirSync(out, { recursive: true });
|
|
const downloads = join(out, "downloads");
|
|
mkdirSync(downloads, { recursive: true });
|
|
|
|
const profile = mkdtempSync(join(tmpdir(), "ec-chrome-"));
|
|
const proc = spawn(chrome, [
|
|
...(headless ? ["--headless=new"] : []),
|
|
`--remote-debugging-port=${port}`,
|
|
`--user-data-dir=${profile}`,
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--enable-unsafe-webgpu",
|
|
"--window-size=1600,1000",
|
|
"about:blank",
|
|
], { stdio: "ignore" });
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
let ws;
|
|
let nextId = 1;
|
|
const waiting = new Map();
|
|
const logs = [];
|
|
|
|
async function connect() {
|
|
for (let i = 0; i < 100; i++) {
|
|
try {
|
|
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
const page = list.find((t) => t.type === "page");
|
|
if (page) {
|
|
ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
|
ws.onmessage = (m) => {
|
|
const msg = JSON.parse(m.data);
|
|
if (msg.id && waiting.has(msg.id)) {
|
|
waiting.get(msg.id)(msg);
|
|
waiting.delete(msg.id);
|
|
} else if (msg.method === "Runtime.consoleAPICalled") {
|
|
logs.push(msg.params.args.map((a) => a.value ?? a.description ?? "").join(" "));
|
|
} else if (msg.method === "Runtime.exceptionThrown") {
|
|
logs.push("EXCEPTION " + JSON.stringify(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
|
|
}
|
|
};
|
|
return;
|
|
}
|
|
} catch {}
|
|
await sleep(100);
|
|
}
|
|
throw new Error("Chrome did not start");
|
|
}
|
|
|
|
function send(method, params = {}) {
|
|
const id = nextId++;
|
|
ws.send(JSON.stringify({ id, method, params }));
|
|
return new Promise((res, rej) => waiting.set(id, (m) => (m.error ? rej(new Error(`${method}: ${m.error.message}`)) : res(m.result))));
|
|
}
|
|
|
|
async function js(expr, timeout = 300000) {
|
|
// (promise rejections carry plain strings: wrap them so the message survives)
|
|
const wrapped = `(async () => { try { return await (${expr}); } catch (e) { throw new Error(String(e && e.message || e)); } })()`;
|
|
const r = await send("Runtime.evaluate", { expression: wrapped, awaitPromise: true, returnByValue: true, timeout });
|
|
if (r.exceptionDetails) throw new Error(`${expr}: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
|
|
return r.result.value;
|
|
}
|
|
|
|
const check = (cond, msg) => {
|
|
if (!cond) throw new Error(`check failed: ${msg}`);
|
|
};
|
|
|
|
// Wait until the app runs (after a navigation or reload).
|
|
async function ready(ms = 180000) {
|
|
await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", ms);
|
|
const load = await js("window.effectcraftLoad");
|
|
if (load.error) throw new Error(load.error);
|
|
return load;
|
|
}
|
|
|
|
async function idle() {
|
|
await until(`effectcraft.execute("renderQueue.list", {}).then(r => !r.rendering)`, 300000, 200);
|
|
}
|
|
|
|
async function shot(name) {
|
|
const r = await send("Page.captureScreenshot", { format: "png" });
|
|
const p = join(out, `${name}.png`);
|
|
writeFileSync(p, Buffer.from(r.data, "base64"));
|
|
return p;
|
|
}
|
|
|
|
async function until(expr, ms = 60000, step = 200) {
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < ms) {
|
|
if (await js(expr)) return Date.now() - t0;
|
|
await sleep(step);
|
|
}
|
|
throw new Error(`timeout waiting for ${expr}`);
|
|
}
|
|
|
|
async function waitDownload(pred, ms = 120000) {
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < ms) {
|
|
const f = readdirSync(downloads).find((n) => pred(n) && !n.endsWith(".crdownload"));
|
|
if (f) return { name: f, bytes: statSync(join(downloads, f)).size };
|
|
await sleep(200);
|
|
}
|
|
throw new Error("no download");
|
|
}
|
|
|
|
const report = { url, steps: {} };
|
|
let failed = false;
|
|
try {
|
|
await connect();
|
|
await send("Page.enable");
|
|
await send("Runtime.enable");
|
|
await send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: downloads }).catch(() => {});
|
|
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
|
const t0 = Date.now();
|
|
await send("Page.navigate", { url });
|
|
report.load = await ready();
|
|
report.load.wallMs = Date.now() - t0;
|
|
report.info = await js("effectcraft.info()");
|
|
// Storage: OPFS where the browser writes it (Chrome does), else IndexedDB.
|
|
check(["opfs", "indexeddb"].includes(report.info.storage?.backend), `storage backend ${JSON.stringify(report.info.storage)}`);
|
|
|
|
// The demo comp in the viewer: wait for the first viewer frame (renderMs > 0).
|
|
const ms = await until("effectcraft.inspect().then(i => i.renderMs > 0)", 120000, 500);
|
|
await sleep(1500);
|
|
const insp = await js("effectcraft.inspect()");
|
|
report.steps.viewer = { waitMs: ms, renderMs: insp.renderMs, activeComp: insp.activeComp, window: insp.window, screenshot: await shot("01-demo") };
|
|
|
|
// GPU: with WebGPU, eframe runs on it and the compositor renders viewer frames on the GPU
|
|
// (no readback); without it, WebGL2 / CPU.
|
|
const info = await js("effectcraft.info()");
|
|
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
|
|
if (info.webgpu && info.backend === "BrowserWebGpu") {
|
|
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
|
|
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
|
|
}
|
|
|
|
// Comp pixels through the API.
|
|
const f = await js("effectcraft.renderFrame({max_side: 480}).then(r => ({w: r.width, h: r.height, pngBytes: atob(r.png).length}))");
|
|
report.steps.renderFrame = f;
|
|
|
|
// Viewer frames render in frame workers (project replicas fed by diffs): with the CPU
|
|
// renderer, scrubbing (and an edit, which travels as a diff) keeps the page's event loop free.
|
|
await js(`effectcraft.execute("render.backend", {backend: "cpu"})`);
|
|
report.steps.frameWorkers = await js(`(async () => {
|
|
const before = effectcraft.info().frameWorkers;
|
|
let maxGapMs = 0, last = performance.now();
|
|
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
|
|
await effectcraft.execute("layer.newSolid", {name: "Frame Worker Solid", color: "#3366cc", width: 120, height: 80});
|
|
for (let k = 0; k < 12; k++) {
|
|
await effectcraft.execute("time.set", {time: k / 4});
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
}
|
|
const t0 = performance.now();
|
|
while (effectcraft.info().frameWorkers.rendered < before.rendered + 3 && performance.now() - t0 < 120000) await new Promise((r) => setTimeout(r, 100));
|
|
clearInterval(timer);
|
|
await effectcraft.execute("edit.undo", {});
|
|
return {before, after: effectcraft.info().frameWorkers, maxGapMs};
|
|
})()`);
|
|
const fwk = report.steps.frameWorkers;
|
|
await js(`effectcraft.execute("render.backend", {backend: "gpu"})`);
|
|
check(fwk.after.alive > 0, `frame workers alive: ${JSON.stringify(fwk.after)}`);
|
|
check(fwk.after.rendered >= fwk.before.rendered + 3, `frames rendered in workers: ${JSON.stringify(fwk)}`);
|
|
check(fwk.after.syncs >= 2, `project synced as diffs: ${JSON.stringify(fwk.after)}`);
|
|
check(fwk.maxGapMs < 400, `event loop blocked ${fwk.maxGapMs} ms while scrubbing`);
|
|
|
|
// Render Queue: a short, small GIF → download. `renderQueue.render` waits by default; the
|
|
// render still runs in a worker and the reply comes when it ends (the page keeps running).
|
|
const rqT = Date.now();
|
|
// (`ui.menu.invoke`: `engine.execute`'s strict parameter check does not know the spread
|
|
// Render Settings / Output Module keys)
|
|
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "smoke_[compName].gif", resolution: 0.25, timeSpan: "custom", start: 0, end: 1}})`);
|
|
const waited = await js(`(async () => {
|
|
let maxGapMs = 0, last = performance.now();
|
|
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
|
|
const r = await effectcraft.execute("renderQueue.render", {});
|
|
clearInterval(timer);
|
|
return {rendering: r.rendering, status: r.items.map((i) => i.status), maxGapMs};
|
|
})()`);
|
|
const gif = await waitDownload((n) => n.endsWith(".gif"));
|
|
report.steps.gifExport = { ...gif, ms: Date.now() - rqT, waited };
|
|
check(!waited.rendering && waited.status.every((s) => s === "Done"), `wait:true replies when done: ${JSON.stringify(waited)}`);
|
|
check(waited.maxGapMs < 400, `event loop blocked ${waited.maxGapMs} ms during a waiting render`);
|
|
await idle();
|
|
|
|
// An image sequence arrives as one .zip.
|
|
await js(`effectcraft.execute("renderQueue.setRender", {index: 1, render: false})`);
|
|
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "png", output: "seq_[####].png", resolution: 0.125, timeSpan: "custom", start: 0, end: 0.2}})`);
|
|
await js(`effectcraft.execute("renderQueue.render", {})`);
|
|
report.steps.pngSequence = await waitDownload((n) => n.endsWith(".zip"));
|
|
await idle();
|
|
|
|
// A background render (`wait: false`, what the Render button does) runs in a Web Worker: the
|
|
// page keeps its event loop. Measure the longest gap between 10 ms timers while it renders.
|
|
await js(`effectcraft.execute("renderQueue.setRender", {index: 2, render: false})`);
|
|
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "bg_[compName].gif", resolution: 0.5, timeSpan: "custom", start: 0, end: 4}})`);
|
|
report.steps.backgroundRender = await js(`(async () => {
|
|
const t0 = performance.now();
|
|
const r = await effectcraft.execute("renderQueue.render", {wait: false});
|
|
const callMs = performance.now() - t0;
|
|
let maxGapMs = 0, last = performance.now(), ticks = 0, progress = 0;
|
|
for (;;) {
|
|
await new Promise((res) => setTimeout(res, 10));
|
|
const now = performance.now();
|
|
maxGapMs = Math.max(maxGapMs, now - last);
|
|
last = now;
|
|
if (++ticks % 5) continue;
|
|
const l = await effectcraft.execute("renderQueue.list", {});
|
|
if (l.progress) progress = Math.max(progress, l.progress.done);
|
|
if (!l.rendering) break;
|
|
}
|
|
return {startedRendering: r.rendering, callMs, renderMs: performance.now() - t0, maxGapMs, ticks, progressFrames: progress, workers: effectcraft.info().workers};
|
|
})()`);
|
|
const bg = report.steps.backgroundRender;
|
|
bg.download = await waitDownload((n) => n.startsWith("bg_") && n.endsWith(".gif"));
|
|
check(bg.startedRendering, "render started in the background");
|
|
check(bg.maxGapMs < 400, `event loop blocked ${bg.maxGapMs} ms during the render`);
|
|
check(bg.progressFrames > 0, "progress reported while rendering");
|
|
|
|
// Audio: the AudioContext starts on a user gesture.
|
|
report.steps.audio = { before: (await js("effectcraft.info()")).audio };
|
|
check(report.steps.audio.before.state !== "running", "audio context not running before a gesture");
|
|
// (a click in the window's bottom-right corner, the status bar: modifier keys alone don't
|
|
// count as user activation)
|
|
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1590, y: 995, button: "left", clickCount: 1 });
|
|
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: 1590, y: 995, button: "left", clickCount: 1 });
|
|
await until("effectcraft.info().audio.state === 'running'", 10000);
|
|
report.steps.audio.afterGesture = (await js("effectcraft.info()")).audio;
|
|
// A 2 s tone in the comp; preview playback feeds the output and the meters.
|
|
await js(`(async () => {
|
|
const rate = 48000, n = 2 * rate, b = new ArrayBuffer(44 + n * 4), v = new DataView(b);
|
|
const w = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
|
|
w(0, "RIFF"); v.setUint32(4, 36 + n * 4, true); w(8, "WAVE"); w(12, "fmt "); v.setUint32(16, 16, true);
|
|
v.setUint16(20, 1, true); v.setUint16(22, 2, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 4, true);
|
|
v.setUint16(32, 4, true); v.setUint16(34, 16, true); w(36, "data"); v.setUint32(40, n * 4, true);
|
|
for (let i = 0; i < n; i++) { const x = Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 0.5 * 32767); v.setInt16(44 + 4 * i, x, true); v.setInt16(46 + 4 * i, x, true); }
|
|
return effectcraft.addFile(new File([b], "tone.wav"));
|
|
})()`);
|
|
await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300);
|
|
// Media Browser: browser storage (where tone.wav now is) and its actions.
|
|
const mb = await js(`effectcraft.execute("mediaBrowser.go", {})`);
|
|
report.steps.mediaBrowser = { path: mb.path, entries: mb.entries.map((e) => e.name), places: mb.places, actions: mb.actions.map((a) => a.id) };
|
|
check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`);
|
|
check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…");
|
|
const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`);
|
|
check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
|
|
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
|
await js(`effectcraft.execute("time.set", {time: 0})`);
|
|
await js(`effectcraft.request("ui.playback", {action: "play"})`);
|
|
await sleep(1500);
|
|
const pb = await js(`effectcraft.request("ui.playback", {action: "status"})`);
|
|
const au = (await js("effectcraft.info()")).audio;
|
|
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
|
report.steps.audio.playing = { playback: pb, output: au };
|
|
check(pb.audio, "audio preview running");
|
|
check(au.posted > 0.5 * au.sampleRate, `audio fed (${au.posted} frames)`);
|
|
check(au.played > 0, "audio clock advancing");
|
|
check(Math.max(...(pb.levelsDb || [-99])) > -20, `meters (${JSON.stringify(pb.levelsDb)})`);
|
|
|
|
// Save As → .ecproj download; reopen it.
|
|
await js(`effectcraft.execute("file.saveAs", {path: "/smoke.ecproj"})`);
|
|
const proj = await waitDownload((n) => n.endsWith(".ecproj"));
|
|
report.steps.save = proj;
|
|
await js(`effectcraft.execute("file.newProject", {})`);
|
|
const opened = await js(`(async () => { const d = effectcraft.readFile("/smoke.ecproj"); const p = await effectcraft.addFile(new File([d], "smoke-copy.ecproj")); await new Promise(r => setTimeout(r, 800)); return {path: p, files: effectcraft.files()}; })()`);
|
|
const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`);
|
|
if (!comps) throw new Error("reopened project has no active comp");
|
|
report.steps.reopen = { ...opened, activeComp: comps };
|
|
report.steps.final = { screenshot: await shot("02-after") };
|
|
|
|
// Persistence: the session, imported media, settings and a project saved to browser storage
|
|
// survive a reload; File ▸ Open Recent reopens the project.
|
|
await js(`effectcraft.execute("layer.newSolid", {name: "Persisted Solid", color: "#22aa66"})`);
|
|
await js(`effectcraft.execute("prefs.set", {key: "general.recentItems", value: 7})`);
|
|
const saved = await js(`effectcraft.saveToBrowser("/persist-test.ecproj")`);
|
|
await js(`effectcraft.execute("layer.newSolid", {name: "Unsaved Solid"})`);
|
|
await sleep(1500); // the session snapshot is taken at most every second
|
|
await js("effectcraft.flush()");
|
|
const before = await js("effectcraft.listStored()");
|
|
const tr = Date.now();
|
|
await send("Page.reload", {});
|
|
await sleep(500);
|
|
const load2 = await ready();
|
|
const after = await js("effectcraft.listStored()");
|
|
const info2 = await js("effectcraft.info()");
|
|
const layers = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
|
|
const recentItems = await js(`effectcraft.execute("prefs.get", {key: "general.recentItems"})`);
|
|
const rec = await js(`effectcraft.execute("file.recoveryInfo", {})`);
|
|
const paths = after.files.map((f) => f.path);
|
|
report.steps.persistence = { saved, reloadMs: Date.now() - tr, load: load2, restored: info2.restored, storage: info2.storage, layers, recentItems, recent: rec.recentProjects, stored: paths, usage: after.usage, filesBefore: before.files.length };
|
|
check(info2.restored, "session restored after reload");
|
|
check(layers.includes("Persisted Solid") && layers.includes("Unsaved Solid"), `layers after reload: ${layers}`);
|
|
check(paths.includes("/tone.wav") && paths.includes("/persist-test.ecproj"), `stored files: ${paths}`);
|
|
check(recentItems === 7, `setting after reload: ${recentItems}`);
|
|
check(rec.recentProjects[0] === "/persist-test.ecproj", `recent projects: ${rec.recentProjects}`);
|
|
await js(`effectcraft.execute("file.openRecent", {index: 0})`);
|
|
const reopened = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
|
|
check(reopened.includes("Persisted Solid") && !reopened.includes("Unsaved Solid"), `Open Recent: ${reopened}`);
|
|
// The imported tone is still in the project and decodes from storage after the reload.
|
|
const tone = await js(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => "ok", (e) => String(e))`);
|
|
check(tone === "ok", `tone.wav after reload: ${tone}`);
|
|
report.steps.persistence.openRecent = reopened;
|
|
|
|
// PWA: manifest, service worker in control, and an offline reload.
|
|
const pwa = await js(`(async () => {
|
|
const m = await (await fetch("manifest.webmanifest")).json();
|
|
const reg = await navigator.serviceWorker.getRegistration();
|
|
const keys = await caches.keys();
|
|
const cached = keys.length ? (await (await caches.open(keys[0])).keys()).length : 0;
|
|
return {manifest: m.name, icons: m.icons.length, display: m.display, sw: !!(reg && reg.active), controlled: !!navigator.serviceWorker.controller, caches: keys, cached};
|
|
})()`);
|
|
check(pwa.sw && pwa.cached > 4, `service worker: ${JSON.stringify(pwa)}`);
|
|
await send("Network.enable", {});
|
|
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
|
await send("Page.reload", {});
|
|
await sleep(500);
|
|
const offline = await ready();
|
|
const info3 = await js("effectcraft.info()");
|
|
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
|
report.steps.pwa = { ...pwa, offlineLoad: offline, offlineControlled: info3.serviceWorker, crossOriginIsolated: info3.crossOriginIsolated };
|
|
check(info3.serviceWorker, "offline reload served by the service worker");
|
|
check(info3.crossOriginIsolated, "cross-origin isolated under the service worker");
|
|
report.steps.final2 = { screenshot: await shot("03-offline") };
|
|
} catch (e) {
|
|
failed = true;
|
|
report.error = String(e && e.stack || e);
|
|
try { report.errorShot = await shot("error"); } catch {}
|
|
}
|
|
report.console = logs.slice(-40);
|
|
writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2));
|
|
console.log(JSON.stringify(report, null, 2));
|
|
try { ws.close(); } catch {}
|
|
proc.kill();
|
|
process.exit(failed ? 1 : 0);
|