File size: 23,867 Bytes
19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c f469961 19b3f9c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 | /**
* demo/showcase.ts
*
* Three-persona collaboration showcase for recording a mini product
* video. An empty article gets turned into a compact research piece
* on Transformer attention by three agents working in concert:
*
* - Bob writes "The recipe" (top): definition, block-math
* formula, softmax explanation. Mid-cycle he fires a
* chat citation tool call that adds the Vaswani 2017
* paper to the bibliography.
* - Alice (hero window) writes "Intuition" (middle): an analogy
* with inline $Q$ / $K$ / $V$, bolds a key phrase via the
* in-editor bubble toolbar, then asks the chat agent to
* rephrase the closing line (AgentRewrite typewriter).
* At the end she scrolls to the top, slowly drags the
* Hue slider (warm yellow -> cool blue, article retints
* live including the neural-net banner), clicks
* Publish, waits for the SSE pipeline to succeed, and
* finally clicks "View published article" so the
* recording ends on the real rendered output - the
* "ship it" payoff.
* - Carol writes "In Python" (bottom): intro line, link action
* on "PyTorch" (points at the real
* scaled_dot_product_attention docs), 8-line code
* block, Vaswani closing line.
*
* Three authors (Hugging Face, MIT/ETH Zurich, Stanford), a neural
* network banner, a live hue drag, three agent tool-calls (bubble
* bold + chat rephrase + citation + link), and a real publish
* (Publishing... Rendering HTML... Rasterizing embeds...
* Published!) land in a ~60s budget.
*
* By default:
* - Alice is the "hero" window (visible). This is what you record.
* - Bob and Carol run headless. Their yjs cursors, comments and
* edits appear in Alice's window as if real teammates joined.
*
* Flags:
* --all-visible Launch Bob and Carol visibly too (debug layout).
* --record Recording-friendly mode for Alice (no toolbar /
* tabs / URL bar / automation banner, OS
* fullscreen, persistent context). Bob and Carol
* are forced headless. A 5s countdown gives you
* time to start your screen recorder.
* --capture macOS only. Implies --record. Automatically
* starts `screencapture -v` before the demo and
* stops it when the scenario ends. The resulting
* .mov is saved under demo/recordings/. First run
* triggers a Screen Recording permission prompt;
* grant it to `Terminal` (or iTerm).
*
* Prereqs:
* - Frontend dev server on http://localhost:5678
* - Backend dev server on http://localhost:8080
*/
import { runAlice } from "./alice.js";
import { runBob } from "./bob.js";
import { runCarol } from "./carol.js";
import { resetDoc } from "./lib/editor-actions.js";
import { PERSONAS } from "./personas.js";
import {
killLeftoverChromiums,
launchPersona,
shutdownAllPersonas,
type PersonaHandle,
} from "./lib/chromium.js";
import {
buildRecordingPath,
recordingCountdown,
startScreenRecording,
type ScreenRecording,
} from "./lib/recording.js";
import { connectPersona, type PersonaHandle as YjsPersona } from "./lib/yjs-client.js";
import type { Page } from "playwright";
// ---------------------------------------------------------------------------
// Post-parallel doc verification
// ---------------------------------------------------------------------------
/**
* Expected text fragments (substrings, case-sensitive) for each slot
* a persona is supposed to own after the parallel phase. Strings not
* positions, because Yjs syncs can still be in flight when we read.
*
* If a fragment is missing in its expected slot, or shows up in a
* DIFFERENT section's slot, that's a collision - the kind of bug
* user reports as "agents stepping on each other's toes".
*/
const SLOT_EXPECTATIONS: Array<{
heading: string;
slot: number;
mustInclude: string;
owner: "alice" | "bob" | "carol";
}> = [
// Bob - "The recipe" (4 slots: def, block-math, softmax, multi-head)
{
heading: "The recipe",
slot: 0,
mustInclude: "Attention lets a token",
owner: "bob",
},
// slot 1 is a <blockMath> atom; checked separately below.
{
heading: "The recipe",
slot: 2,
mustInclude: "softmax turns those similarity scores",
owner: "bob",
},
{
heading: "The recipe",
slot: 3,
mustInclude: "multi-head",
owner: "bob",
},
// Alice - "Intuition"
{
heading: "Intuition",
slot: 0,
mustInclude: "Imagine each token asking",
owner: "alice",
},
{
heading: "Intuition",
slot: 1,
// Post-rephrase. If the rephrase did not run we still accept the
// original wording so we don't false-positive on a failed chat
// action (different bug category).
mustInclude: "every token",
owner: "alice",
},
{
heading: "Intuition",
slot: 2,
mustInclude: "fully differentiable",
owner: "alice",
},
// Carol - "In Python"
{
heading: "In Python",
slot: 0,
mustInclude: "eight lines of PyTorch",
owner: "carol",
},
// slot 1 is the code block; tested separately below
{
heading: "In Python",
slot: 2,
mustInclude: "Every modern LLM",
owner: "carol",
},
];
/**
* Inspect the shared Y.XmlFragment directly (no browser needed)
* and return a flat "sections" view matching what the old
* DOM-based verifier produced, so `SLOT_EXPECTATIONS` keeps
* working unchanged.
*/
async function verifyFinalDoc(bob: YjsPersona): Promise<void> {
// We import yjs lazily here to avoid a top-level dep circle with
// `./lib/yjs-client.ts`; the verifier only runs once at the end
// of a demo so the extra dynamic import is cheap.
const Y = await import("yjs");
const sections: Array<{
heading: string;
slots: string[];
slotTags: string[];
}> = [];
let current: {
heading: string;
slots: string[];
slotTags: string[];
} | null = null;
// textContent for a Y.XmlElement: concatenate every descendant
// Y.XmlText, skipping inline atom nodes (inlineMath, citation)
// which have no textContent of their own but carry meaningful
// attributes. We still keep their node name in the tag so the
// flat text search below can spot a citation chip by inference.
// Y.XmlText.toString() serializes marks as XML tags (`<bold>foo</bold>`,
// `<link href="...">bar</link>`), which breaks "expected substring"
// checks like `"eight lines of PyTorch"` when a mark like link wraps
// the trailing word. We read the Delta format instead and only keep
// the `insert` text values: gives us the plain concatenated text a
// reader would see, mark attributes ignored.
const xmlTextToPlain = (t: Y.XmlText): string => {
const delta = t.toDelta() as Array<{ insert?: unknown }>;
let out = "";
for (const op of delta) {
if (typeof op.insert === "string") out += op.insert;
}
return out;
};
const textOf = (el: Y.XmlElement): string => {
// Atom nodes (`blockMath`, `inlineMath`) carry their whole
// payload on the `latex` attribute and have no children, so
// the child-walker below would see an empty slot. Surface the
// LaTeX verbatim when the slot IS an atom.
if (el.nodeName === "blockMath" || el.nodeName === "inlineMath") {
const latex = el.getAttribute("latex");
return typeof latex === "string" ? latex.replace(/\s+/g, " ").trim() : "";
}
let out = "";
const visit = (node: Y.XmlElement | Y.XmlText) => {
if (node instanceof Y.XmlText) {
out += xmlTextToPlain(node);
} else if (node instanceof Y.XmlElement) {
if (node.nodeName === "citation") {
const label = node.getAttribute("label");
if (typeof label === "string") out += label;
} else if (node.nodeName === "inlineMath" || node.nodeName === "blockMath") {
const latex = node.getAttribute("latex");
if (typeof latex === "string") out += latex;
} else {
for (const child of node.toArray()) visit(child as Y.XmlElement | Y.XmlText);
}
}
};
for (const child of el.toArray()) {
visit(child as Y.XmlElement | Y.XmlText);
}
return out.replace(/\s+/g, " ").trim();
};
for (const child of bob.fragment.toArray()) {
if (!(child instanceof Y.XmlElement)) continue;
if (child.nodeName === "heading" && Number(child.getAttribute("level")) === 2) {
if (current) sections.push(current);
current = {
heading: textOf(child),
slots: [],
slotTags: [],
};
} else if (current) {
const tagMap: Record<string, string> = {
paragraph: "P",
codeBlock: "PRE",
heading: "H",
bibliography: "BIB",
blockMath: "MATH",
};
current.slots.push(textOf(child));
current.slotTags.push(tagMap[child.nodeName] ?? child.nodeName.toUpperCase());
}
}
if (current) sections.push(current);
const flatText = sections
.flatMap((s) => [s.heading, ...s.slots])
.join(" ")
.replace(/\s+/g, " ")
.trim();
const report = { sections, flatText };
if (!report.sections.length) {
console.warn("[verify] empty fragment, nothing to verify");
return;
}
console.log("[verify] ----- doc layout -----");
for (const s of report.sections) {
console.log(`[verify] ## ${s.heading} (${s.slots.length} slots)`);
s.slots.forEach((txt, i) => {
const tag = s.slotTags[i];
const trimmed = txt.length > 80 ? txt.slice(0, 80) + "..." : txt;
console.log(`[verify] [${i}] <${tag}> ${trimmed || "(empty)"}`);
});
}
const anomalies: string[] = [];
for (const exp of SLOT_EXPECTATIONS) {
const section = report.sections.find((s) =>
s.heading.toLowerCase().includes(exp.heading.toLowerCase()),
);
if (!section) {
anomalies.push(`missing section: "${exp.heading}"`);
continue;
}
const slotText = section.slots[exp.slot] ?? "";
if (!slotText.includes(exp.mustInclude)) {
// Did the expected fragment land elsewhere in the doc?
const leakedInto = report.sections
.flatMap((s) =>
s.slots.map((txt, i) => ({
heading: s.heading,
slot: i,
text: txt,
})),
)
.find(
(loc) =>
loc.text.includes(exp.mustInclude) &&
!(loc.heading === section.heading && loc.slot === exp.slot),
);
if (leakedInto) {
anomalies.push(
`${exp.owner}: "${exp.mustInclude}" expected in ` +
`${exp.heading}[${exp.slot}] but landed in ` +
`${leakedInto.heading}[${leakedInto.slot}] ` +
`- collision with neighbour section`,
);
} else if (report.flatText.includes(exp.mustInclude)) {
anomalies.push(
`${exp.owner}: "${exp.mustInclude}" present in doc but not in ` +
`${exp.heading}[${exp.slot}] (slot has "${slotText.slice(0, 40)}...")`,
);
} else {
anomalies.push(
`${exp.owner}: "${exp.mustInclude}" missing from the doc entirely`,
);
}
}
}
// Code block sanity check: "In Python" slot 1 should be a <pre> /
// codeblock node, and contain "def attention". Anything else means
// Carol's code got absorbed into the wrong node.
const inPython = report.sections.find((s) =>
s.heading.toLowerCase().includes("in python"),
);
if (inPython) {
const codeTag = inPython.slotTags[1];
const codeText = inPython.slots[1] ?? "";
if (codeTag !== "PRE" && !codeText.includes("def attention")) {
anomalies.push(
`carol: code block in "In Python"[1] not recognized ` +
`(got tag=${codeTag}, text="${codeText.slice(0, 40)}...")`,
);
}
}
// Block math sanity check: "The recipe" slot 1 should be a
// <blockMath> atom carrying the attention formula. We grep the
// LaTeX source for the two signature substrings the formula
// always contains ("Attention" + "softmax") so a typo in the
// source string doesn't silently pass.
const recipe = report.sections.find((s) =>
s.heading.toLowerCase().includes("the recipe"),
);
if (recipe) {
const mathTag = recipe.slotTags[1];
const mathText = recipe.slots[1] ?? "";
const looksLikeFormula =
mathText.includes("Attention") && mathText.includes("softmax");
if (mathTag !== "MATH" || !looksLikeFormula) {
anomalies.push(
`bob: block math in "The recipe"[1] not recognized ` +
`(got tag=${mathTag}, latex="${mathText.slice(0, 60)}...")`,
);
}
}
// Bibliography should exist (Bob's citation action creates it).
if (!report.flatText.includes("Vaswani")) {
anomalies.push("bob: citation 'Vaswani' never landed in the doc");
}
if (anomalies.length === 0) {
console.log("[verify] OK - no slot collisions detected");
} else {
console.log(`[verify] ${anomalies.length} anomalie(s) detected:`);
for (const a of anomalies) console.log(`[verify] - ${a}`);
}
}
interface CliArgs {
url: string;
speed: number;
allVisible: boolean;
/**
* Recording mode for Alice: chrome-less app window, true OS
* fullscreen, automation banner suppressed, and Bob/Carol forced
* headless so they never appear in the capture. Includes a short
* countdown before the demo starts so you can hit "record" on
* QuickTime / Loom / OBS.
*/
record: boolean;
/**
* macOS-only: auto-record the main display with `screencapture -v`
* while the demo runs. Implies record mode. Output goes to
* demo/recordings/.
*/
capture: boolean;
/**
* Recording duration in seconds (passed to `screencapture -V`).
*
* WHY this exists: sending SIGINT/SIGTERM/SIGHUP to
* `screencapture -v` aborts the capture WITHOUT flushing the .mov.
* The only reliable way to get a playable file is to let the
* `-V <seconds>` timer elapse naturally. So we size this to
* "demo duration + a few seconds of outro", and the script blocks
* on the timer before exiting.
*
* Default 50s covers our ~45s demo + a ~5s outro on the published
* article (user wants the browser to quit shortly after the
* publish view, not linger for 30s). Bump if you expand the
* scenario.
*/
captureDuration: number;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
url: "http://localhost:5678",
speed: 2,
allVisible: false,
record: false,
capture: false,
captureDuration: 50,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--url") args.url = argv[++i];
else if (a === "--speed") args.speed = Number(argv[++i]);
else if (a === "--all-visible") args.allVisible = true;
else if (a === "--record") args.record = true;
else if (a === "--capture") args.capture = true;
else if (a === "--capture-duration")
args.captureDuration = Number(argv[++i]);
}
// --capture only makes sense alongside --record.
if (args.capture) args.record = true;
return args;
}
async function run() {
const args = parseArgs(process.argv.slice(2));
console.log(
`[showcase] target=${args.url} speed=${args.speed}x allVisible=${args.allVisible} record=${args.record}`,
);
// Nuke any orphaned Chromium left over from a previous run (e.g.
// demo killed with Ctrl+C before it could close its browsers).
// Without this we get stale Yjs connections / extra windows
// cluttering the screen.
killLeftoverChromiums();
// Handles initialized lazily; kept in an array so SIGINT /
// finally can nuke every browser that did get launched, even if
// a later launch threw before we got to `Promise.all(runAlice,
// ...)`.
const handles: Array<PersonaHandle | null> = [];
// Bob + Carol are now Node-side Yjs peers (no Chromium); kept
// separately so SIGINT / finally can tear their provider sockets
// down cleanly alongside the browsers.
const yjsPeers: YjsPersona[] = [];
let recording: ScreenRecording | null = null;
let alreadyCleanedUp = false;
const cleanup = async (reason: string) => {
if (alreadyCleanedUp) return;
alreadyCleanedUp = true;
console.log(`[showcase] cleanup (${reason})`);
if (recording) {
try {
recording.abort();
} catch {
/* ignore */
}
recording = null;
}
for (const peer of yjsPeers) {
try {
peer.destroy();
} catch {
/* provider already closed */
}
}
await shutdownAllPersonas(handles);
console.log("[showcase] done.");
};
// Signal handlers: no matter the mode, Ctrl+C and SIGTERM always
// trigger full cleanup so the user never ends up with orphan
// Chromium windows consuming CPU / holding onto the Yjs server
// connection.
const onSignal = (sig: string) => {
cleanup(sig)
.catch(() => {})
.finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
};
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
try {
// Bob and Carol are ALWAYS headless. Showing them visibly (even
// outside record mode) steals focus from Alice's typing,
// causes keystrokes to race with window-manager animations,
// and generally makes the demo unreliable. --all-visible only
// controls Alice's layout now.
const alicePos = args.allVisible ? { x: 0, y: 0 } : undefined;
const alice = await launchPersona(args.url, {
persona: PERSONAS.alice,
headless: false,
windowPosition: alicePos,
fullscreen: !args.allVisible && !args.record,
recordMode: args.record,
});
handles.push(alice);
// Relay demo-related browser console messages to the Node log so
// we can diagnose silent frontend errors (e.g. "replace target
// not found") during a --capture run without having to open
// DevTools by hand.
alice.page.on("console", (msg) => {
const text = msg.text();
if (/__demo-chat|\[alice\]|agentRewrite/.test(text)) {
console.log(`[alice:console ${msg.type()}] ${text}`);
}
});
console.log("[showcase] alice up");
// Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
// Carol join, so they connect to a clean slate (no leftover
// banner / body / title from a previous demo run). Idempotent:
// a no-op on a fresh server.
console.log("[showcase] resetting shared document");
await resetDoc(alice.page);
// Bob and Carol connect as Node-side Yjs peers instead of
// full Chromium browsers. They appear in Alice's editor via
// normal collaboration sync (authors list, text edits,
// citations) without any DOM typing or PM position race.
// URL transformation: http:// -> ws:// on the /collab path.
const wsUrl = args.url.replace(/^http/, "ws") + "/collab";
const bob = await connectPersona({
url: wsUrl,
docName: "default",
user: { name: PERSONAS.bob.name, color: PERSONAS.bob.color },
});
yjsPeers.push(bob);
console.log("[showcase] bob up (yjs peer)");
const carol = await connectPersona({
url: wsUrl,
docName: "default",
user: { name: PERSONAS.carol.name, color: PERSONAS.carol.color },
});
yjsPeers.push(carol);
console.log("[showcase] carol up (yjs peer)");
// Auto screen recording takes precedence over the manual
// countdown: if --capture is on, we start `screencapture -v`
// immediately and give the first frame a beat to settle
// before content starts typing.
if (args.capture) {
const outputPath = buildRecordingPath();
recording = await startScreenRecording(outputPath, args.captureDuration);
if (recording) {
console.log(
`[showcase] screen recording started (${args.captureDuration}s max) -> ${recording.outputPath}`,
);
await new Promise((r) => setTimeout(r, 1500));
} else {
console.warn("[showcase] falling back to manual countdown");
}
}
if (args.record && !recording) {
await recordingCountdown(5);
}
// T0 marks the start of the actual demo content (after the
// browsers and Yjs reset). Useful to measure the perceived
// "video length" independently of Playwright cold-start time.
const T0 = Date.now();
const stamp = (tag: string) =>
console.log(
`[showcase] +${((Date.now() - T0) / 1000).toFixed(2)}s ${tag}`,
);
stamp("demo phase start");
// Bob / Carol settle first; Alice keeps running through rephrase
// + hue + publish. We wait for Bob and Carol explicitly so we
// can inspect the shared doc from Bob's editor page WHILE Alice
// is still wrapping up. That way the diagnostic reads the post-
// parallel state (no publish navigation has hijacked Bob's page
// yet) and we can log any collision BEFORE the screencast ends.
const alicePromise = runAlice(alice.page, args.speed)
.then(() => stamp("alice done"))
.catch((err) => console.error("[alice] error:", err));
const bobPromise = runBob(bob, args.speed)
.then(() => stamp("bob done"))
.catch((err) => console.error("[bob] error:", err));
const carolPromise = runCarol(carol, args.speed)
.then(() => stamp("carol done"))
.catch((err) => console.error("[carol] error:", err));
await Promise.all([bobPromise, carolPromise]);
// Wait for Alice too: her rephrase typewriter + slot 2 wrap-up
// keeps writing AFTER Bob/Carol finish. Running verify before
// Alice is done used to show phantom "missing fragments" simply
// because the rephrase animation had not landed yet.
await alicePromise;
stamp("all personas finished");
// Post-parallel doc verification: read the shared Y.XmlFragment
// directly from Bob's still-connected Yjs peer. Alice's browser
// has navigated to the published output by this point, but the
// Yjs provider keeps syncing regardless of which page is on
// screen, so Bob's handle always reflects the final CRDT state.
try {
await verifyFinalDoc(bob);
} catch (err) {
console.warn(
"[verify] aborted:",
err instanceof Error ? err.message : err,
);
}
// Block on the screencapture timer so the user gets a complete,
// playable .mov. Extra time after "all personas finished"
// looks fine in the recording (final state of the editor,
// good outro frame). If the demo finished before the -V timer
// elapses, we wait here; otherwise this resolves immediately.
if (recording) {
const remainingMs = Math.max(0, recording.finishesAt - Date.now());
if (remainingMs > 0) {
const secs = (remainingMs / 1000).toFixed(1);
console.log(
`[showcase] waiting ${secs}s for screencapture to flush the .mov...`,
);
}
await recording.waitForFinish();
console.log(`[showcase] recording saved: ${recording.outputPath}`);
// Don't let `cleanup()` kill screencapture after it finished
// cleanly.
recording = null;
}
} finally {
// Always close browsers, regardless of mode or whether the demo
// succeeded. Previously dev mode kept browsers open "for
// inspection", but orphan Chromium windows after every run are
// more annoying than the occasional loss of a final doc state.
await cleanup("run complete");
}
}
run().catch(async (err) => {
console.error("[showcase] fatal:", err);
// Even on fatal errors, guarantee no leftover Chromium /
// screencapture.
try {
killLeftoverChromiums();
} catch {
/* ignore */
}
process.exit(1);
});
|