Files
DumiFlex 02d7bf18c0 test(tutorials): the full tutorial curriculum, 51 videos (#281)
All 51 video tutorials as Watchwright tests in tutorials/ (T00-T12, A01-A19, L01-L06, N01-N05, E1-E4, AI1-AI4), with the Tutor fixes found while recording them.
2026-11-05 02:39:53 +01:00

1018 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The tutorial presenter: what makes a test look like a video someone made on purpose. A steady
// pointer that moves like a hand (cursor.js draws it), narration captions timed to the voice
// lines, callouts, and the canvas moves a person makes (add a node through search, drag a wire,
// press Run). Every step goes through the real UI with the visible mouse; nothing is set behind
// the viewer's back except the start state.
//
// Timing: `say(line)` shows the caption and returns at once, so the hands work while the voice
// speaks; the next `say` (or `done`) waits until the line has been spoken. A line's length comes
// from voice/lines.json (written by voice/speak.py); a line not spoken yet gets an estimate.
//
// The cues (when each line starts, what it says, the fast-forward ranges) are saved with the
// attempt as cues.json; render.mjs turns the attempt's video + cues into the finished MP4.
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type { Locator, Page } from "playwright";
import { StepError, test, type Helpers, type TestMeta } from "watchwright";
/** The page script that draws the pointer, captions and callouts (Watchwright runs a cached copy of the pack's code, so files are found through the pack folder). */
export const cursorScript = (packDir: string) => path.join(packDir, "cursor.js");
const linesFile = (packDir: string) => path.join(packDir, "voice", "lines.json");
/** A narration line's id: the same text always gets the same voice file. */
export const lineId = (text: string) => createHash("sha1").update(text.trim()).digest("hex").slice(0, 12);
interface VoiceLine {
text: string;
ms: number;
}
function voiceLines(packDir: string): Record<string, VoiceLine> {
const file = linesFile(packDir);
if (!existsSync(file)) return {};
try {
return JSON.parse(readFileSync(file, "utf8")) as Record<string, VoiceLine>;
} catch {
return {};
}
}
/** About how long a line takes to say, for lines without a voice file yet. */
const estimate = (text: string) => Math.max(1600, Math.round(text.length * 62));
export interface Cue {
id: string;
text: string;
/** Start, ms from the start of the test body. */
at: number;
ms: number;
voiced: boolean;
}
export interface FastRange {
from: number;
to: number;
/** How much faster the final video plays this part. */
speed: number;
}
type Point = { x: number; y: number };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
export class Tutor {
readonly page: Page;
/** WP Debug's node id once addDebugUnder placed it. */
debugId: string | null = null;
private readonly lines: Record<string, VoiceLine>;
private readonly t0 = Date.now();
private pos: Point = { x: 960, y: 560 };
private busyUntil = 0;
/** The first move after a line waits until then, so the hand acts as the voice says it, not ahead of it. */
private leadUntil = 0;
private hideTimer: ReturnType<typeof setTimeout> | null = null;
/** The caption on screen and when it goes, so a page load can put it back. */
private caption: { text: string; until: number } | null = null;
readonly cues: Cue[] = [];
readonly fast: FastRange[] = [];
/** Where the video starts showing the tutorial (after the canvas has loaded). */
startAt = 0;
/** When the sync mark went up (render.mjs finds it in the video). */
markAt = 0;
/** Every sync mark (ms): render.mjs lines the video up at each, since the recorder's clock drifts under load. */
marks: number[] = [];
constructor(
readonly h: Helpers,
readonly packDir: string,
readonly params: Record<string, unknown> = {},
) {
this.page = h.page;
this.lines = voiceLines(packDir);
}
/** The ComfyUI address the take runs against. */
get origin(): string {
return this.h.baseURL ? new URL(this.h.baseURL).origin : "http://127.0.0.1:8189";
}
private now(): number {
return Date.now() - this.t0;
}
// ---- narration ---------------------------------------------------------------------------
/** Shows a narration line and starts its voice; returns while it is spoken. */
async say(text: string): Promise<void> {
await this.waitVoice();
const id = lineId(text);
const known = this.lines[id];
const ms = known?.ms ?? estimate(text);
const at = this.now();
this.cues.push({ id, text, at, ms, voiced: !!known });
// Each line also flashes the sync mark (render.mjs paints it out), so the voice can't drift from the picture.
const mark = at - (this.marks[this.marks.length - 1] ?? -Infinity) > 900;
if (mark) this.marks.push(at);
this.busyUntil = Date.now() + ms + 350;
this.leadUntil = Date.now() + Math.min(ms * 0.45, 2200);
if (this.hideTimer) clearTimeout(this.hideTimer);
this.caption = { text, until: Date.now() + ms + 500 };
await this.page
.evaluate(
({ t, mark }) => {
window.__tut?.caption(t);
if (mark) window.__tut?.mark(400);
},
{ t: text, mark },
)
.catch(() => undefined);
this.hideTimer = setTimeout(() => {
this.caption = null;
void this.page.evaluate(() => window.__tut?.caption("")).catch(() => undefined);
}, ms + 500);
this.h.log(`say ${id}: ${text}`);
}
/** Waits until the line being spoken has finished. */
async waitVoice(): Promise<void> {
const left = this.busyUntil - Date.now();
if (left > 0) await sleep(left);
}
/** A short beat, so the viewer can look at what just changed. */
beat(ms = 700): Promise<void> {
return sleep(ms);
}
/** Waits for the voice, clears captions and callouts, and saves the cues with the attempt. */
async done(): Promise<void> {
await this.waitVoice();
await sleep(600);
if (this.hideTimer) clearTimeout(this.hideTimer);
await this.page.evaluate(() => window.__tut?.clear()).catch(() => undefined);
await sleep(900);
const missing = this.cues.filter((c) => !c.voiced).length;
await this.syncMark();
if (missing) this.h.note(`${missing} line(s) have no voice yet: run python tutorials/voice/speak.py and record again.`);
await this.h.file("cues.json", JSON.stringify({ t0: this.t0, mark: this.markAt, start: this.startAt, cues: this.cues, fast: this.fast, marks: this.marks, ended: this.now() }, null, 1), "application/json");
}
/** Marks the start of the tutorial: the video before it (loading the page) is cut. */
async begin(): Promise<void> {
await this.page.mouse.move(this.pos.x, this.pos.y);
await sleep(500);
await this.syncMark();
this.markAt = this.marks[0] ?? 0;
await sleep(300);
this.startAt = this.now();
await sleep(600);
}
/** Flashes the sync mark and notes when; render.mjs finds each one in the video. */
async syncMark(): Promise<void> {
const last = this.marks[this.marks.length - 1];
if (last !== undefined) await sleep(Math.max(0, last + 900 - this.now()));
this.marks.push(this.now());
await this.page.evaluate(() => window.__tut?.mark(400)).catch(() => undefined);
await sleep(400 + 600);
}
/** Runs `fn`; the final video plays that part `speed` times faster (a long wait, a model run), or cuts it out with speed 0. */
async fastForward<T>(fn: () => Promise<T>, speed = 6): Promise<T> {
// A caption still on screen would flash past (or be cut) in the video, so let the line finish first.
await this.waitVoice();
if (this.caption) await sleep(Math.max(0, this.caption.until - Date.now()));
const from = this.now();
try {
return await fn();
} finally {
// A mark inside the sped-up part, where nobody sees it, re-lines the voice up after every wait.
await this.syncMark();
this.fast.push({ from, to: this.now(), speed });
}
}
// ---- the hand ------------------------------------------------------------------------------
/** Holds the first action after a line until the voice is partway into it. */
private async lead(): Promise<void> {
const left = this.leadUntil - Date.now();
this.leadUntil = 0;
if (left > 0) await sleep(left);
}
/** Moves the pointer to a point along a gentle curve, about as fast as a hand. */
async moveTo(to: Point, { speed = 1 }: { speed?: number } = {}): Promise<void> {
await this.lead();
const from = this.pos;
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.hypot(dx, dy);
if (dist < 2) return;
const ms = Math.min(1100, 260 + dist * 0.55) / speed;
const steps = Math.max(8, Math.round(ms / 16));
// A slight bow to one side, like a wrist turning.
const bow = Math.min(60, dist * 0.08);
const nx = -dy / dist;
const ny = dx / dist;
for (let i = 1; i <= steps; i++) {
const t = easeInOut(i / steps);
const arc = Math.sin(Math.PI * t) * bow;
await this.page.mouse.move(from.x + dx * t + nx * arc, from.y + dy * t + ny * arc);
await sleep(ms / steps);
}
this.pos = { ...to };
}
/** The middle of a locator on screen (waits for it to be visible). */
async centre(target: Locator, offset: Partial<Point> = {}): Promise<Point> {
await target.waitFor({ state: "visible", timeout: 15_000 });
const box = await target.boundingBox();
if (!box) throw new StepError("The part to point at isn't on screen.");
return { x: box.x + (offset.x ?? box.width / 2), y: box.y + (offset.y ?? box.height / 2) };
}
/** Moves to the target and clicks it. */
async click(target: Locator | Point, { double = false, right = false }: { double?: boolean; right?: boolean } = {}): Promise<void> {
const isPoint = "x" in target && "y" in target && typeof target.x === "number";
if (!isPoint) await this.bringIntoView(target as Locator);
const at = isPoint ? (target as Point) : await this.centre(target as Locator);
await this.moveTo(at);
await sleep(140);
const button = right ? "right" : "left";
await this.page.mouse.down({ button });
await sleep(70);
await this.page.mouse.up({ button });
if (double) {
// clickCount 2 makes the browser fire dblclick (DOM listeners need it; LiteGraph times clicks itself).
await sleep(90);
await this.page.mouse.down({ clickCount: 2 });
await sleep(60);
await this.page.mouse.up({ clickCount: 2 });
}
await sleep(260);
}
/** Scrolls a part into view first when it sits under the page's edges (a sticky footer, the caption). */
private async bringIntoView(target: Locator): Promise<void> {
// On the ComfyUI canvas the wheel zooms instead of scrolling, so leave the view alone there.
if (!this.inManager()) return;
await target.waitFor({ state: "attached", timeout: 15_000 });
const view = this.page.viewportSize() ?? { width: 1920, height: 1080 };
const box = await target.boundingBox();
if (box && (box.y < 60 || box.y + box.height > view.height - 170)) await this.scrollTo(target, { at: 0.4 });
}
/** Points at a target (hover), without clicking. */
async hover(target: Locator): Promise<void> {
await this.moveTo(await this.centre(target));
await sleep(300);
}
/** Drags from one point to another with the button held (a wire, a node move). */
async drag(from: Point, to: Point): Promise<void> {
await this.moveTo(from);
await sleep(160);
await this.page.mouse.down();
await sleep(120);
await this.moveTo(to, { speed: 0.8 });
await sleep(160);
await this.page.mouse.up();
await sleep(300);
}
/** Types like a person: one key at a time. */
async type(text: string, { cps = 22 }: { cps?: number } = {}): Promise<void> {
await this.lead();
await this.page.keyboard.type(text, { delay: Math.round(1000 / cps) });
await sleep(300);
}
async press(key: string): Promise<void> {
await this.lead();
await sleep(120);
await this.page.keyboard.press(key);
await sleep(250);
}
/**
* Picks an option in a native drop-down. Its own pop-up list isn't in the recording, so the hand
* goes to it and the value changes in place.
*/
async choose(select: Locator, value: string): Promise<void> {
await this.moveTo(await this.centre(select));
await sleep(250);
await select.selectOption(value);
await sleep(600);
}
/** Clicks a field, clears it and types `text` into it. */
async fill(target: Locator, text: string, { cps = 22 }: { cps?: number } = {}): Promise<void> {
await this.click(target);
await this.page.keyboard.press("ControlOrMeta+A");
await this.page.keyboard.press("Backspace");
await this.type(text, { cps });
}
/**
* Scrolls with the wheel, the pointer over the part that scrolls, until `target`'s top sits in
* the upper part of the screen (`at`, a share of the height). Already there: no scroll.
*/
/** Whether the page is the manager (`/wp/…`), where the wheel scrolls; on the canvas it zooms. */
private inManager(): boolean {
return new URL(this.page.url()).pathname.startsWith("/wp");
}
async scrollTo(target: Locator, { at = 0.22 }: { at?: number } = {}): Promise<void> {
if (!this.inManager()) return;
await target.waitFor({ state: "attached", timeout: 15_000 });
const view = this.page.viewportSize() ?? { width: 1920, height: 1080 };
let first = await target.boundingBox();
if (!first) return;
if (first.y >= 90 && first.y <= view.height * 0.55) return;
await this.moveTo({ x: Math.min(view.width - 60, Math.max(60, first.x + first.width / 2)), y: view.height * 0.5 });
let last = first.y;
let stuck = 0;
for (let i = 0; i < 60; i++) {
const box = await target.boundingBox();
if (!box) break;
const dy = box.y - view.height * at;
if (Math.abs(dy) < 12) break;
await this.page.mouse.wheel(0, Math.sign(dy) * Math.min(Math.abs(dy), 120));
await sleep(60);
const now = (await target.boundingBox())?.y ?? box.y;
stuck = Math.abs(now - last) < 1 ? stuck + 1 : 0;
if (stuck >= 3) break;
last = now;
}
await sleep(400);
}
/**
* Opens a page of ComfyUI (`/`, which comes up empty, like at the start) or the manager
* (`/wp/…`). The page load is cut out of the video, after the line being spoken has finished.
*/
async go(pathname: string): Promise<void> {
await this.fastForward(async () => {
await this.page.goto(`${this.origin}${pathname}`, { waitUntil: "domcontentloaded" });
if (pathname === "/" || pathname === "") {
await this.page.waitForFunction(() => !!window.app?.graph && !!window.LiteGraph, null, { timeout: 120_000 });
await sleep(1500);
await clearCanvas(this.page);
} else {
await this.page.waitForLoadState("networkidle", { timeout: 20_000 }).catch(() => undefined);
// The manager paints its page a moment after the network goes quiet; leaving the cut
// before that showed a black frame (L05 at Settings › Advanced).
await this.page
.waitForFunction(() => document.querySelectorAll("[data-test]").length > 3, null, { timeout: 10_000 })
.catch(() => undefined);
await this.page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null)))));
await sleep(600);
}
await this.page.mouse.move(this.pos.x, this.pos.y);
}, 0);
const c = this.caption;
if (c && c.until > Date.now()) await this.page.evaluate((t) => window.__tut?.caption(t), c.text).catch(() => undefined);
await sleep(300);
}
// ---- callouts ------------------------------------------------------------------------------
/** Draws a box around a part of the screen with an optional label; `uncallout` removes it. */
async callout(id: string, target: Locator | Locator[], label?: string, pad = 0): Promise<void> {
const boxes = [];
for (const one of Array.isArray(target) ? target : [target]) {
await one.waitFor({ state: "visible", timeout: 15_000 });
const b = await one.boundingBox();
if (b) boxes.push(b);
}
if (!boxes.length) return;
const x = Math.min(...boxes.map((b) => b.x)) - pad;
const y = Math.min(...boxes.map((b) => b.y)) - pad;
const box = {
x,
y,
width: Math.max(...boxes.map((b) => b.x + b.width)) + pad - x,
height: Math.max(...boxes.map((b) => b.y + b.height)) + pad - y,
};
await this.page.evaluate(([i, b, l]) => window.__tut?.callout(i, b, l), [id, box, label ?? ""] as const);
}
async uncallout(id: string): Promise<void> {
await this.page.evaluate((i) => window.__tut?.uncallout(i), id);
}
// ---- the canvas ----------------------------------------------------------------------------
/** A node on the canvas (Vue nodes mode). */
node(id: string | number): Locator {
return this.page.locator(`[data-node-id="${id}"]`);
}
/**
* Opens a Context node's Add module picker, ticks the named library items and adds them.
* A name matches the row whose name is exactly it ("Hero", not "Hero fits the place").
*/
async pickModules(ctxId: string, names: string[]): Promise<void> {
await this.click(this.node(ctxId).locator('[data-testid="open-picker"]'));
const page = this.page;
for (const name of names) {
const row = page.locator('[data-test^="picker-row-"]').filter({ has: page.getByText(name, { exact: true }) }).first();
// Rows far down the list aren't drawn until scrolled to: search for those, as a person would.
const search = page.locator('[data-testid="picker-search"] input, input[data-testid="picker-search"]').first();
const shown = (await row.count()) > 0;
if (shown) await this.scrollInList(row, page.locator(".wp-picker__body").first());
else {
await this.click(search);
await this.type(name, { cps: 18 });
await sleep(600);
}
for (let tries = 0; tries < 2; tries++) {
await this.click(row.locator('[data-test="picker-checkbox"]').first());
if ((await row.getAttribute("data-checked")) !== null) break;
await sleep(400);
}
if ((await row.getAttribute("data-checked")) === null) throw new StepError(`Couldn't tick "${name}" in the picker.`);
if (!shown) {
// Escape in a search box with text clears it (and keeps the picker open).
await this.click(search);
await this.press("Escape");
await sleep(400);
}
}
await sleep(300);
await this.click(page.locator('[data-test="picker-add-btn"]'));
await sleep(800);
}
/** Wheel-scrolls a scrolling list (`list`) until `row` sits fully inside it. */
async scrollInList(row: Locator, list: Locator): Promise<void> {
for (let i = 0; i < 30; i++) {
const rb = await row.boundingBox();
const lb = await list.boundingBox();
if (!rb || !lb) return;
const above = rb.y < lb.y + 4;
const below = rb.y + rb.height > lb.y + lb.height - 4;
if (!above && !below) break;
if (i === 0) await this.moveTo({ x: lb.x + lb.width / 2, y: lb.y + lb.height / 2 });
const dy = below ? rb.y + rb.height - (lb.y + lb.height) + 24 : rb.y - lb.y - 24;
await this.page.mouse.wheel(0, Math.sign(dy) * Math.min(Math.abs(dy), 120));
await sleep(80);
}
await sleep(250);
}
/** A module row on a Context node, by the module's name. */
moduleRow(ctxId: string, name: string): Locator {
const exact = new RegExp(`^\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`);
return this.node(ctxId).locator(".wp-module").filter({ has: this.page.locator(".wp-module-name", { hasText: exact }) }).first();
}
/** Clicks the globe on these module rows, so their variables stay out of the prompt. */
/** Opens folded bundle frames on a Context node (sub-bundles too), or only inside the bundle `name`. */
async expandBundles(ctxId: string, name?: string): Promise<void> {
const scope = name ? this.bundleFrame(ctxId, name) : this.node(ctxId);
for (let i = 0; i < 12; i++) {
const fold = scope.getByRole("button", { name: "Expand bundle" }).first();
if (!(await fold.count())) return;
await this.click(fold);
await sleep(450);
}
}
/** Folds the bundle `name` on a Context node when it is open. */
async foldBundle(ctxId: string, name: string): Promise<void> {
const header = this.node(ctxId).locator(".wp-bundle-header").filter({ hasText: name }).first();
const btn = header.getByRole("button", { name: "Collapse bundle" });
if (await btn.count()) {
await this.click(btn.first());
await sleep(500);
}
}
/** The frame of a bundle (or sub-bundle) on a Context node, by its name. */
bundleFrame(ctxId: string, name: string): Locator {
return this.node(ctxId).locator(".wp-bundle").filter({ has: this.page.locator(".wp-bundle-header").filter({ hasText: name }) }).last();
}
async hide(ctxId: string, names: string[]): Promise<void> {
for (const name of names) {
await this.click(this.moduleRow(ctxId, name).locator('[data-test="row-action-internal"]'));
await sleep(450);
}
}
/** Node ids on the canvas now. */
private async nodeIds(): Promise<string[]> {
return this.page.evaluate(() => (window.app?.graph?.nodes ?? []).map((n) => String(n.id)));
}
/**
* Adds a node the way a person does: double-click empty canvas at `at`, type `search`, Enter,
* then click to drop it there. Returns the new node's id.
*/
async addNode(search: string, wanted: Point): Promise<string> {
const before = new Set(await this.nodeIds());
// The double click must land on empty canvas. A node that grew (a preview row, a longer list)
// or a floating panel can cover the planned point: then drop the new node just below it.
const at = await this.page.evaluate((p) => {
let { x, y } = p;
for (let i = 0; i < 4; i++) {
const el = document.elementFromPoint(x, y);
if (!el || el.tagName === "CANVAS" || el.classList.contains("lgraphcanvas")) break;
// A node, or a panel floating over the canvas (the bookmark dock): go below it.
const hit = el.closest("[data-node-id]") ?? el;
y = hit.getBoundingClientRect().bottom + 40;
}
return { x, y: Math.min(y, window.innerHeight - 60) };
}, wanted);
// The search box opens on a double click on empty canvas; when the click is taken for something
// else (a click just before it), try once more rather than typing into the canvas.
const searchOpen = () =>
this.page.evaluate(() => {
// Only the node search box counts: a focused field on a node would take the typed name.
const el = document.activeElement;
return el?.tagName === "INPUT" && !el.closest("[data-node-id]") && !!el.closest('[role="dialog"], .comfy-vue-node-search-container, .p-dialog, .litesearchbox');
});
await this.click(at, { double: true });
await sleep(500);
if (!(await searchOpen())) {
await sleep(600);
await this.click(at, { double: true });
await sleep(600);
if (!(await searchOpen())) throw new StepError(`The node search didn't open at ${Math.round(at.x)}, ${Math.round(at.y)}.`);
}
await this.type(search, { cps: 16 });
await sleep(700);
await this.press("Enter");
await sleep(400);
// The new node follows the pointer until a click drops it.
await this.moveTo({ x: at.x + 6, y: at.y + 4 }, { speed: 2 });
await this.click({ x: at.x + 6, y: at.y + 4 });
await sleep(600);
const added = (await this.nodeIds()).filter((id) => !before.has(id));
if (added.length !== 1) throw new StepError(`Adding "${search}" made ${added.length} new node(s).`);
const id = added[0] as string;
// A seed that changes after each run would make the video disagree with its narration.
await this.page.evaluate((nid) => {
const node = window.app?.graph?.getNodeById(nid) as { widgets?: { name: string; value: unknown; linkedWidgets?: { name: string; value: unknown }[] }[] } | null | undefined;
for (const w of node?.widgets ?? []) {
if (w.name === "control_after_generate") w.value = "fixed";
for (const l of w.linkedWidgets ?? []) if (l.name === "control_after_generate") l.value = "fixed";
}
}, id);
return id;
}
/** The on-screen centre of a node's slot dot, by the slot's name. */
async slot(nodeId: string, side: "in" | "out", name: string): Promise<Point> {
const index = await this.page.evaluate(
([id, s, n]) => {
const node = window.app?.graph?.getNodeById(id) ?? window.app?.graph?.getNodeById(Number(id));
const list = (s === "in" ? node?.inputs : node?.outputs) ?? [];
return list.findIndex((x) => x.name === n);
},
[nodeId, side, name] as const,
);
if (index < 0) throw new StepError(`Node ${nodeId} has no ${side === "in" ? "input" : "output"} "${name}".`);
return this.centre(this.page.locator(`[data-slot-key="${nodeId}-${side}-${index}"]`));
}
/** Drags a wire from one node's output to another node's input. */
async wire(fromId: string, output: string, toId: string, input: string): Promise<void> {
const from = await this.slot(fromId, "out", output);
const to = await this.slot(toId, "in", input);
await this.drag(from, to);
const linked = await this.page.evaluate(
([id, n]) => {
const node = window.app?.graph?.getNodeById(id) ?? window.app?.graph?.getNodeById(Number(id));
return (node?.inputs ?? []).some((x) => x.name === n && x.link != null);
},
[toId, input] as const,
);
if (!linked) throw new StepError(`The wire into ${input} didn't connect.`);
}
/** Presses Run and waits until the queue is empty again. */
async run(): Promise<void> {
await this.click(this.page.getByRole("button", { name: "Run", exact: true }).first());
await sleep(800);
const end = await this.h.waitUntil(
async () => {
const q = await this.h.api("/queue");
const j = q.json as { queue_running?: unknown[]; queue_pending?: unknown[] } | undefined;
return !!j && (j.queue_running?.length ?? 0) === 0 && (j.queue_pending?.length ?? 0) === 0;
},
{ timeout: 10 * 60_000, interval: 500, what: "the run to finish" },
);
if (end !== "done") throw new StepError("The run didn't finish.");
await sleep(600);
}
}
// ---- showcase: real images from the prompt -------------------------------------------------------
export interface ShowcaseOptions {
/** The node whose `prompt` (and `negative`) outputs feed the sampler. */
from: string;
/** Where the image preview goes (its top-left corner on screen) and how big it is. */
preview: Point & { width?: number; height?: number };
/** Where the small, folded loader + sampler stack goes. */
chain: Point;
/** How many runs. */
runs?: number;
/** Before each run after the first: change something on screen (a new seed, a switch). */
between?: (i: number) => Promise<void>;
}
/** Screen point → graph position, at the canvas's current pan and zoom. */
async function toGraph(page: Page, p: Point): Promise<[number, number]> {
return page.evaluate(([x, y]) => {
const app = window.app as unknown as { canvas: { ds: { scale: number; offset: [number, number] }; canvas: HTMLCanvasElement } };
const r = app.canvas.canvas.getBoundingClientRect();
const { scale, offset } = app.canvas.ds;
return [(x - r.left) / scale - offset[0], (y - r.top) / scale - offset[1]] as [number, number];
}, [p.x, p.y] as const);
}
/**
* Adds a WP Debug node under a Context node and wires the Context into it, the way a person does,
* so each run shows the picks that went into its picture. Returns the Debug node's id.
*/
export async function addDebugUnder(t: Tutor, ctxId: string): Promise<string> {
const view = t.page.viewportSize() ?? { width: 1920, height: 1080 };
let box = await t.node(ctxId).boundingBox();
// A tall Context leaves no room under it: fold its rows (Debug shows the picks anyway).
if (box && box.y + box.height > view.height - 470) {
await t.click(t.node(ctxId).getByRole("button", { name: "Collapse all modules" }).first());
await sleep(600);
box = await t.node(ctxId).boundingBox();
}
const x = box ? box.x + box.width / 2 : 520;
const below = box ? box.y + box.height + 40 : 600;
const y = Math.min(below + 100, view.height - 320);
const dbg = await t.addNode("WP Debug", { x, y });
// Snug under the Context (the drop point centres it, which can leave a gap or overlap).
await t.page.evaluate(
({ ctxId, dbg }) => {
type N = { pos: [number, number]; size: [number, number] };
const app = window.app as unknown as { graph: { getNodeById(id: string | number): N | null }; canvas: { setDirty(a: boolean, b: boolean): void } };
const get = (id: string) => app.graph.getNodeById(id) ?? app.graph.getNodeById(Number(id));
const c = get(ctxId);
const d = get(dbg);
if (!c || !d) return;
d.pos = [c.pos[0], c.pos[1] + c.size[1] + 70];
app.canvas.setDirty(true, true);
},
{ ctxId, dbg },
);
await sleep(300);
await t.wire(ctxId, "context", dbg, "context");
t.debugId = dbg;
await fitDebug(t);
return dbg;
}
/** Keeps WP Debug's bottom above the caption; a run fills it with rows and it grows, so showcase calls this after each run (it scrolls inside). */
export async function fitDebug(t: Tutor): Promise<void> {
const dbg = t.debugId;
if (!dbg) return;
const view = t.page.viewportSize() ?? { width: 1920, height: 1080 };
const top = (await t.node(dbg).boundingBox())?.y;
if (top === undefined) return;
const room = view.height - 165 - top;
await t.page.evaluate(
({ id, room }) => {
const app = window.app as unknown as { graph: { getNodeById(id: string | number): { size: [number, number] } | null }; canvas: { ds: { scale: number }; setDirty(a: boolean, b: boolean): void } };
const n = app.graph.getNodeById(id) ?? app.graph.getNodeById(Number(id));
if (!n) return;
n.size = [n.size[0], Math.max(130, room / app.canvas.ds.scale)];
app.canvas.setDirty(true, true);
},
{ id: dbg, room },
);
await sleep(300);
}
/** Whether takes make real images (the pack's `showcase` setting; it needs a model, so it's off in the cloud). */
export function showcasing(t: Tutor): boolean {
return t.params.showcase === true;
}
/**
* Wires the prompt into a checkpoint, sampler and image preview, runs it `runs` times and lets each
* image land on screen. The stack of loader, encoders and sampler is built folded, since the video
* is about the prompt; the narration around it is the test's. Returns the preview node's id.
*/
export async function showcase(t: Tutor, o: ShowcaseOptions): Promise<string> {
const ckpt = String(t.params.checkpoint ?? "");
const info = await t.h.api("/object_info/CheckpointLoaderSimple");
const known = ((info.json as Record<string, { input?: { required?: { ckpt_name?: [string[]] } } }> | undefined)?.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? []) as string[];
const name = known.find((k) => k === ckpt || k.replace(/\.(safetensors|ckpt)$/i, "") === ckpt.replace(/\.(safetensors|ckpt)$/i, ""));
if (!name) throw new StepError(`The checkpoint "${ckpt}" isn't in ComfyUI's models/checkpoints. It has: ${known.slice(0, 8).join(", ") || "none"}.`);
const chainAt = await toGraph(t.page, o.chain);
const previewAt = await toGraph(t.page, o.preview);
const steps = Number(t.params.steps ?? 24);
const cfg = Number(t.params.cfg ?? 5);
const previewId = await t.page.evaluate(
({ from, name, chainAt, previewAt, size, steps, cfg }) => {
type LNode = {
id: number;
pos: [number, number];
size: [number, number];
flags: { collapsed?: boolean };
widgets?: { name: string; value: unknown }[];
inputs?: { name: string }[];
outputs?: { name: string }[];
connect(slot: number, target: LNode, targetSlot: number): unknown;
};
const LG = window.LiteGraph as { createNode(type: string): LNode };
const graph = (window.app as unknown as { graph: { add(n: LNode): void; getNodeById(id: string | number): LNode | null } }).graph;
const src = graph.getNodeById(from) ?? graph.getNodeById(Number(from));
if (!src) throw new Error("no prompt node");
const make = (type: string, x: number, y: number, fold = true) => {
const n = LG.createNode(type);
n.pos = [x, y];
graph.add(n);
if (fold) n.flags.collapsed = true;
return n;
};
const set = (n: LNode, w: string, v: unknown) => {
const widget = n.widgets?.find((x) => x.name === w);
if (widget) widget.value = v;
};
const inIdx = (n: LNode, s: string) => (n.inputs ?? []).findIndex((x) => x.name === s);
const outIdx = (n: LNode, s: string) => (n.outputs ?? []).findIndex((x) => x.name === s);
const link = (a: LNode, out: string, b: LNode, inp: string) => {
const i = outIdx(a, out);
const j = inIdx(b, inp);
if (i < 0 || j < 0) throw new Error(`can't wire ${out} → ${inp}`);
a.connect(i, b, j);
};
const [cx, cy] = chainAt;
const load = make("CheckpointLoaderSimple", cx, cy);
set(load, "ckpt_name", name);
const pos = make("CLIPTextEncode", cx, cy + 50);
const neg = make("CLIPTextEncode", cx, cy + 100);
const latent = make("EmptyLatentImage", cx, cy + 150);
set(latent, "width", 832);
set(latent, "height", 1216);
const sampler = make("KSampler", cx, cy + 200);
set(sampler, "steps", steps);
set(sampler, "cfg", cfg);
set(sampler, "sampler_name", "euler_ancestral");
set(sampler, "seed", 1234);
for (const w of sampler.widgets ?? []) if (w.name === "control_after_generate") w.value = "fixed";
const decode = make("VAEDecode", cx, cy + 250);
// Keep the preview clear of the nodes already on screen (their real height depends on the
// fonts and the template's length), and inside the view above the caption.
const title = (window.LiteGraph as unknown as { NODE_TITLE_HEIGHT?: number }).NODE_TITLE_HEIGHT ?? 30;
const others = (graph as unknown as { _nodes: LNode[] })._nodes.filter((n) => !n.flags.collapsed);
const view = (window.app as unknown as { canvas: { ds: { scale: number; offset: [number, number] }; canvas: HTMLCanvasElement } }).canvas;
const box = view.canvas.getBoundingClientRect();
const viewBottom = (box.height - 150) / view.ds.scale - view.ds.offset[1];
const viewRight = (box.width - 30) / view.ds.scale - view.ds.offset[0];
let [px, py] = previewAt;
let [pw, ph] = size;
for (let pass = 0; pass < 4; pass++) {
let moved = false;
for (const n of others) {
const top = n.pos[1] - title;
const bottom = n.pos[1] + n.size[1];
const apart = px + pw <= n.pos[0] || n.pos[0] + n.size[0] <= px || py + ph <= top || bottom <= py - title;
if (apart) continue;
moved = true;
// Beside it or under it, whichever shows the (square-ish) image bigger.
const right = n.pos[0] + n.size[0] + 30;
const sideW = Math.min(pw, viewRight - right);
const sideH = Math.min(viewBottom - py, (ph * sideW) / pw);
const belowY = bottom + title + 30;
const belowW = Math.min(Math.max(pw, n.size[0]), viewRight - n.pos[0]);
const belowH = Math.min(ph, viewBottom - belowY);
if (sideW >= 220 && Math.min(sideW, sideH) >= Math.min(belowW, belowH)) {
px = right;
pw = sideW;
ph = Math.round(Math.max(220, sideH));
} else {
px = n.pos[0];
py = belowY;
pw = Math.round(belowW);
ph = Math.round(Math.max(220, belowH));
}
}
if (!moved) break;
}
if (py + ph > viewBottom) {
const fit = Math.max(220, viewBottom - py);
pw = Math.round((pw * fit) / ph);
ph = fit;
}
const preview = make("PreviewImage", px, py, false);
preview.size = [pw, ph];
link(load, "CLIP", pos, "clip");
link(load, "CLIP", neg, "clip");
link(src, "prompt", pos, "text");
if (outIdx(src, "negative") >= 0) link(src, "negative", neg, "text");
link(load, "MODEL", sampler, "model");
link(pos, "CONDITIONING", sampler, "positive");
link(neg, "CONDITIONING", sampler, "negative");
link(latent, "LATENT", sampler, "latent_image");
link(sampler, "LATENT", decode, "samples");
link(load, "VAE", decode, "vae");
link(decode, "IMAGE", preview, "images");
(window.app as unknown as { canvas: { setDirty(a: boolean, b: boolean): void } }).canvas.setDirty(true, true);
return String(preview.id);
},
{ from: o.from, name, chainAt, previewAt, size: [o.preview.width ?? 460, o.preview.height ?? 700] as [number, number], steps, cfg },
);
await t.beat(600);
const runs = o.runs ?? 3;
for (let i = 0; i < runs; i++) {
if (i > 0 && o.between) await o.between(i);
await t.fastForward(() => t.run(), 8);
await fitDebug(t);
await t.beat(1800);
}
return previewId;
}
// ---- the canvas start state ----------------------------------------------------------------------
/** Opens the ComfyUI canvas, empty, at 100 %, with the settings the videos are recorded with. */
export async function openCanvas(h: Helpers): Promise<void> {
const origin = h.baseURL ? new URL(h.baseURL).origin : "http://127.0.0.1:8189";
const page = h.page;
await page.goto(`${origin}/`, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => !!window.app?.graph && !!window.LiteGraph, null, { timeout: 120_000 });
await page.waitForTimeout(2500);
await page.evaluate(async () => {
const s = window.app?.extensionManager?.setting;
if (!s) return;
const set = (k: string, v: unknown) => s.set(k, v).catch(() => undefined);
await set("Comfy.VueNodes.Enabled", true);
// DOM widgets vanish below this font size when zoomed out.
await set("LiteGraph.Canvas.MinFontSizeForLOD", 1);
await set("Comfy.Minimap.Visible", false);
await set("Comfy.Graph.CanvasInfo", false);
await set("Comfy.TutorialCompleted", true);
});
// The templates dialog on a first visit, a sidebar left open.
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
await clearCanvas(page);
}
/** Empties the canvas and puts it at 100 %, with no pan. */
async function clearCanvas(page: Page): Promise<void> {
await page.keyboard.press("Escape").catch(() => undefined);
await page.evaluate(() => {
const app = window.app;
if (!app) return;
app.graph.clear();
app.canvas.ds.scale = 1;
app.canvas.ds.offset = [0, 0];
app.canvas.setDirty(true, true);
});
await page.waitForTimeout(800);
}
// ---- a tutorial ----------------------------------------------------------------------------------
export interface TutorialMeta extends TestMeta {
/** What the viewer can do after watching, for the video's description. */
learn?: string;
}
/** A tutorial: one test, one video. Retries are off; a broken take is recorded again. */
export function tutorial(id: string, meta: TutorialMeta, body: (t: Tutor) => Promise<void>): void {
test(id, { runs: 1, section: "Tutorials", ...meta }, async ({ h, packDir, params }) => {
const t = new Tutor(h, packDir, (params ?? {}) as Record<string, unknown>);
await openCanvas(h);
await t.begin();
await body(t);
await t.done();
});
}
declare global {
interface Window {
__tut?: {
mark(ms: number): void;
caption(text: string): void;
callout(id: string, rect: { x: number; y: number; width: number; height: number }, label: string): void;
uncallout(id: string): void;
clear(): void;
};
// The bits of ComfyUI's page the presenter reads.
app?: {
graph: {
nodes: { id: string | number; inputs?: { name: string; link: unknown }[]; outputs?: { name: string }[] }[];
getNodeById(id: string | number): { id: string | number; inputs?: { name: string; link: unknown }[]; outputs?: { name: string }[] } | null;
clear(): void;
};
canvas: { ds: { scale: number; offset: [number, number] }; setDirty(a: boolean, b: boolean): void };
extensionManager?: { setting?: { set(k: string, v: unknown): Promise<void> } };
};
LiteGraph?: unknown;
}
}
// ---- example workflows -----------------------------------------------------------------------------
/**
* Opens one of the extension's example workflows from ComfyUI's Templates browser (Extensions ›
* ComfyUI-Wildcard-Pipeline) and closes the missing-model card it raises when its checkpoint isn't
* installed.
*/
export async function openExample(t: Tutor, title: string): Promise<void> {
const page = t.page;
await t.click(page.getByText("Templates", { exact: true }).first());
await sleep(1500);
const dialog = page.getByRole("dialog").last();
// The extension's entry can sit below the fold of the dialog's nav, and its folder name differs
// between a git clone and a Registry install.
const entry = dialog.getByText(/^\s*comfyui[-_ ]wildcard[-_ ]pipeline\s*$/i).first();
await entry.waitFor({ state: "attached", timeout: 15_000 });
// Other installed packs list their templates beside ours even with custom nodes disabled: keep
// them out of the video by hiding our entry's siblings in the nav (the recording only).
await entry.evaluate((el) => {
const item = el.closest(".cursor-pointer") ?? el.parentElement;
for (const sib of Array.from(item?.parentElement?.children ?? [])) {
if (sib !== item && sib.classList.contains("cursor-pointer")) (sib as HTMLElement).style.display = "none";
}
});
await entry.scrollIntoViewIfNeeded();
await sleep(400);
await t.click(entry);
await sleep(1200);
const card = page.locator(`[data-testid="template-workflow-${title}"]`);
await card.waitFor({ state: "attached", timeout: 15_000 });
await card.scrollIntoViewIfNeeded();
await sleep(400);
await t.click(card);
await page.waitForFunction(() => (window.app?.graph?.nodes?.length ?? 0) > 5, undefined, { timeout: 20_000 });
await sleep(2500);
const missing = page.getByRole("status").filter({ hasText: "is missing" });
if ((await missing.count()) > 0) {
await missing.first().getByRole("button").filter({ hasNotText: "View details" }).first().click().catch(() => undefined);
await sleep(500);
}
}
/** Points every Load Checkpoint at the pack's `checkpoint` (when ComfyUI has it). Returns whether it did. */
export async function useCheckpoint(t: Tutor): Promise<boolean> {
const ckpt = String(t.params.checkpoint ?? "");
if (!ckpt) return false;
const info = await t.h.api("/object_info/CheckpointLoaderSimple");
const known = ((info.json as Record<string, { input?: { required?: { ckpt_name?: [string[]] } } }> | undefined)?.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? []) as string[];
const strip = (s: string) => s.replace(/\.(safetensors|ckpt)$/i, "");
const name = known.find((k) => k === ckpt || strip(k) === strip(ckpt));
if (!name) return false;
await t.page.evaluate((n) => {
type W = { name: string; value: unknown };
const nodes = (window.app?.graph?.nodes ?? []) as unknown as { type: string; widgets?: W[] }[];
for (const node of nodes) {
if (node.type !== "CheckpointLoaderSimple") continue;
const w = node.widgets?.find((x) => x.name === "ckpt_name");
if (w) w.value = n;
}
window.app?.canvas?.setDirty(true, true);
}, name);
return true;
}
// ---- the AI assistant ------------------------------------------------------------------------------
/**
* Waits (sped up in the video) until the assistant is done: nothing on the page says it's working for
* `quiet` ms. The marks are the ones the bench watches.
*/
export async function aiWait(t: Tutor, { quiet = 3000, timeout = 30 * 60_000, speed = 20 }: { quiet?: number; timeout?: number; speed?: number } = {}): Promise<void> {
await t.fastForward(async () => {
const busy = () =>
t.page
.evaluate(() => {
const shown = (e: Element) => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
return [...document.querySelectorAll(".wp-aiw, .wp-wl, [data-test='pack-working'], [aria-busy='true'], [data-test='ai-chat-live'], [data-test='ai-loading']")].some(shown);
})
.catch(() => false);
// The request can start a moment after the click.
const t0 = Date.now();
while (Date.now() - t0 < 15_000 && !(await busy())) await sleep(300);
let calm = Date.now();
const end = await t.h.waitUntil(
async () => {
if (await busy()) calm = Date.now();
return Date.now() - calm >= quiet;
},
{ timeout, interval: 500, what: "the assistant to finish" },
);
if (end !== "done") throw new StepError("The assistant didn't finish.");
}, speed);
}