retake-demos 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dryrun.js ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Dry run — execute a manifest with no camera, and report what would break.
3
+ *
4
+ * Recording is the slow part of a take (a demo has to be performed in real
5
+ * time). Checking that every selector resolves and every wait resolves does
6
+ * not: it runs as fast as the app responds. So the cheap thing should happen
7
+ * first, and the camera should only roll on a manifest that already works.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { runFileOrCommandSeed, resolvePoint, dragPoints } from "./record.js";
12
+ import { chromium } from "playwright";
13
+ import { expandEnv, resolve } from "./manifest.js";
14
+ export async function dryRun(m, manifestDir, log, opts = {}) {
15
+ const q = resolve(m);
16
+ const lines = [];
17
+ let failures = 0;
18
+ // Seeds run here too (file and command kinds — the cheap, out-of-page
19
+ // ones). Without them dry checks a different app state than run will,
20
+ // and a selector failure that is really a state difference looks like a
21
+ // selector failure. --no-seed opts out.
22
+ const fileSeeds = m.seed.filter((s) => s.kind !== "evaluate");
23
+ if (opts.seed !== false && fileSeeds.length) {
24
+ for (const s of fileSeeds)
25
+ await runFileOrCommandSeed(s, manifestDir, log);
26
+ }
27
+ else if (fileSeeds.length) {
28
+ log("dry: seeds skipped (--no-seed) — app state may not match what run will see");
29
+ }
30
+ const browser = await chromium.launch({ headless: true });
31
+ const context = await browser.newContext({
32
+ viewport: q.viewport,
33
+ colorScheme: m.colorScheme,
34
+ ...(m.reducedMotion ? { reducedMotion: "reduce" } : {}),
35
+ });
36
+ const stubs = new Map();
37
+ const arm = async (d) => {
38
+ const key = `${d.method ?? "ANY"} ${d.url}`;
39
+ const fresh = !stubs.has(key);
40
+ stubs.set(key, d);
41
+ if (!fresh)
42
+ return;
43
+ await context.route(d.url, async (route, request) => {
44
+ const cur = stubs.get(key);
45
+ if (cur.method && request.method().toUpperCase() !== cur.method.toUpperCase())
46
+ return route.fallback();
47
+ const body = cur.json !== undefined ? JSON.stringify(cur.json) : cur.from ? fs.readFileSync(path.resolve(manifestDir, expandEnv(cur.from)), "utf8") : "{}";
48
+ await route.fulfill({ status: cur.status ?? 200, contentType: cur.contentType, body });
49
+ });
50
+ };
51
+ const page = await context.newPage();
52
+ try {
53
+ for (const d of m.stub)
54
+ await arm(d);
55
+ // Document first; idle only if it comes quickly. Busy public sites never
56
+ // go network-idle, and the manifest's waitForSelector is the real gate.
57
+ await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 60000 });
58
+ await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
59
+ for (const s of [...(m.auth?.setup ?? []), ...m.setup])
60
+ await run(s, true);
61
+ for (const [i, s] of m.steps.entries())
62
+ await run(s, false, i);
63
+ }
64
+ finally {
65
+ await context.close().catch(() => { });
66
+ await browser.close().catch(() => { });
67
+ }
68
+ async function run(step, isSetup, index = -1) {
69
+ const tag = isSetup ? "setup" : String(index).padStart(3, "0");
70
+ const short = 8000; // long enough for a click that navigates, short enough to stay fast
71
+ try {
72
+ // A point step (click/hover `at`, or a drag) is verified by resolving
73
+ // its target's rect in the page — the same way the recorder will.
74
+ const point = async (t) => {
75
+ if (t && typeof t === "object" && "x" in t)
76
+ return t;
77
+ const sel = typeof t === "string" ? t : t.selector;
78
+ const until = Date.now() + short;
79
+ let box = null;
80
+ while (Date.now() < until) {
81
+ const handle = await page.locator(sel).first().elementHandle({ timeout: Math.max(400, until - Date.now()) });
82
+ if (handle) {
83
+ box = await handle.evaluate((el) => { const r = el.getBoundingClientRect(); return { w: r.width, h: r.height }; });
84
+ await handle.dispose();
85
+ if (box && (box.w || box.h))
86
+ break;
87
+ }
88
+ await page.waitForTimeout(250);
89
+ }
90
+ if (!box)
91
+ throw new Error(`no element for point target "${sel}"`);
92
+ if (!box.w && !box.h)
93
+ throw new Error(`point target "${sel}" never got a size (still hidden or collapsed?)`);
94
+ return resolvePoint(page, t, short);
95
+ };
96
+ // Point steps are PERFORMED here, not just resolved: a dry run that skips
97
+ // the click that opens a panel then reports every later step in that
98
+ // panel as broken. Drags run compressed — the gesture, not the pacing.
99
+ if (step.action === "drag") {
100
+ const f = await point(step.from), t = await point(step.to);
101
+ await dragPoints(page, f, t, { steps: 6, holdMs: 60, durationMs: 240 });
102
+ await page.waitForTimeout(400);
103
+ }
104
+ else if ((step.action === "click" || step.action === "hover") && step.at) {
105
+ const pt = await point(step.at);
106
+ if (step.action === "click") {
107
+ await page.mouse.click(pt.x, pt.y);
108
+ await page.waitForTimeout(400);
109
+ }
110
+ else
111
+ await page.mouse.move(pt.x, pt.y);
112
+ }
113
+ else
114
+ switch (step.action) {
115
+ case "navigate":
116
+ await page.goto(expandEnv(step.url), { waitUntil: "domcontentloaded", timeout: 60000 });
117
+ await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
118
+ break;
119
+ case "waitFor":
120
+ await page.waitForSelector(step.selector, { timeout: Math.min(step.timeout ?? 15000, 15000) });
121
+ break;
122
+ // noWaitAfter: a click that submits a form starts a navigation, and
123
+ // waiting for the element to settle afterwards reports a false failure
124
+ // for a click that actually worked.
125
+ case "click":
126
+ await page.locator(step.selector).waitFor({ timeout: short }); // strict, like the recorder
127
+ await page.locator(step.selector).first().click({ timeout: short, noWaitAfter: true });
128
+ await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => { });
129
+ break;
130
+ case "type":
131
+ await page.locator(step.selector).waitFor({ timeout: short });
132
+ await page.locator(step.selector).fill(expandEnv(step.text), { timeout: short });
133
+ break;
134
+ case "fill":
135
+ await page.locator(step.selector).waitFor({ timeout: short });
136
+ await page.locator(step.selector).fill(expandEnv(step.text), { timeout: short });
137
+ break;
138
+ case "hover":
139
+ await page.locator(step.selector).first().hover({ timeout: short });
140
+ break;
141
+ case "scroll":
142
+ if (step.to)
143
+ await page.locator(step.to).first().boundingBox({ timeout: short });
144
+ break;
145
+ case "upload":
146
+ await page.locator(step.selector).first().waitFor({ timeout: short });
147
+ break;
148
+ case "evaluate":
149
+ await page.evaluate(step.script);
150
+ break;
151
+ case "stub":
152
+ await arm({ url: step.url, method: step.method, status: step.status ?? 200, json: step.json, from: step.from, contentType: "application/json; charset=utf-8" });
153
+ break;
154
+ case "keyboard":
155
+ await page.keyboard.press(step.key);
156
+ break;
157
+ // Pacing does not matter in a dry run, so step waits are compressed.
158
+ // Setup waits are not: they are usually giving a navigation or a login
159
+ // time to finish, and cutting them changes the outcome.
160
+ case "wait":
161
+ await page.waitForTimeout(isSetup ? step.ms : Math.min(step.ms, 400));
162
+ break;
163
+ default: break; // scene, screenshot, zoom, download: nothing to verify
164
+ }
165
+ if (step.pauseAfter)
166
+ await page.waitForTimeout(120);
167
+ }
168
+ catch (e) {
169
+ failures++;
170
+ const why = e.message.split("\n")[0];
171
+ // What was actually on screen matters more than the timeout message:
172
+ // most "selector not found" is really "you are on a different page".
173
+ let where = "";
174
+ try {
175
+ const text = (await page.innerText("body")).replace(/\s+/g, " ").trim().slice(0, 110);
176
+ where = `\n on ${page.url()} — “${text}”`;
177
+ }
178
+ catch { /* page may be gone */ }
179
+ const line = `✗ [${tag}] ${describe(step)} — ${why}${where}`;
180
+ lines.push(line);
181
+ log(line);
182
+ }
183
+ }
184
+ const ok = failures === 0;
185
+ log(ok ? `dry run: all ${m.steps.length} steps resolved` : `dry run: ${failures} step(s) would fail`);
186
+ return { ok, lines, failures };
187
+ }
188
+ function describe(s) {
189
+ if ("selector" in s && s.selector)
190
+ return `${s.action} ${s.selector}`;
191
+ if (s.action === "navigate")
192
+ return `navigate ${s.url}`;
193
+ if (s.action === "scroll" && s.to)
194
+ return `scroll to ${s.to}`;
195
+ return s.action;
196
+ }
package/dist/edits.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Apply structured edits to a manifest file, keeping its comments.
3
+ *
4
+ * The model never writes YAML back. It names an edit from a small vocabulary
5
+ * and Retake makes the change with the document API — so a fix for "slower at
6
+ * the end" cannot also rewrite the captions, and a person's earlier edits
7
+ * survive. Each applied edit is described in one plain sentence for the UI.
8
+ */
9
+ import fs from "node:fs";
10
+ import YAML from "yaml";
11
+ import { Manifest, Step } from "./manifest.js";
12
+ export function applyEdits(file, edits) {
13
+ const doc = YAML.parseDocument(fs.readFileSync(file, "utf8"));
14
+ const done = [];
15
+ const skipped = [];
16
+ let rerecord = false;
17
+ const steps = doc.get("steps");
18
+ const scene = (label) => steps?.items.find((it) => { const n = it; return typeof n.get === "function" && n.get("action") === "scene" && n.get("label") === label; });
19
+ const step = (i) => { const n = steps?.items[Number(i)]; return n && typeof n.get === "function" ? n : undefined; };
20
+ const num = (v) => (typeof v === "number" && isFinite(v) ? v : Number(v));
21
+ for (const e of edits) {
22
+ try {
23
+ switch (e.op) {
24
+ case "set_caption": {
25
+ const n = scene(e.scene);
26
+ if (!n)
27
+ throw new Error(`no scene "${e.scene}"`);
28
+ if (e.caption)
29
+ n.set("caption", String(e.caption));
30
+ else
31
+ n.delete("caption");
32
+ done.push(`Caption on “${e.scene}” → “${e.caption}”`);
33
+ break;
34
+ }
35
+ case "set_camera": {
36
+ const n = scene(e.scene);
37
+ if (!n)
38
+ throw new Error(`no scene "${e.scene}"`);
39
+ const c = e.camera;
40
+ if (c === "static")
41
+ n.set("camera", "static");
42
+ else if (c === "auto")
43
+ n.delete("camera");
44
+ else
45
+ n.set("camera", { zoom: num(c) });
46
+ done.push(`Camera on “${e.scene}” → ${c === "auto" ? "auto" : c === "static" ? "none" : num(c) + "×"}`);
47
+ break;
48
+ }
49
+ case "set_hold": {
50
+ const n = scene(e.scene);
51
+ if (!n)
52
+ throw new Error(`no scene "${e.scene}"`);
53
+ n.set("holdMs", num(e.holdMs));
54
+ done.push(`Hold “${e.scene}” for ${(num(e.holdMs) / 1000).toFixed(1)}s`);
55
+ break;
56
+ }
57
+ case "set_trim": {
58
+ const head = num(e.head ?? 0) || 0, tail = num(e.tail ?? 0) || 0;
59
+ if (!head && !tail)
60
+ doc.delete("trim");
61
+ else
62
+ doc.set("trim", { head, tail });
63
+ done.push(`Trim ${head}s off the start, ${tail}s off the end`);
64
+ break;
65
+ }
66
+ case "set_format": {
67
+ doc.set("preset", String(e.preset));
68
+ done.push(`Format → ${e.preset}`);
69
+ rerecord = true;
70
+ break;
71
+ }
72
+ case "set_layout": {
73
+ doc.set("layout", String(e.layout));
74
+ done.push(`Captions → ${e.layout}`);
75
+ break;
76
+ }
77
+ case "set_cursor": {
78
+ const c = String(e.cursor);
79
+ if (c === "none")
80
+ doc.set("cursor", false);
81
+ else if (c === "touch")
82
+ doc.set("cursor", { style: "touch" });
83
+ else
84
+ doc.delete("cursor");
85
+ done.push(`Cursor → ${c}`);
86
+ rerecord = true;
87
+ break;
88
+ }
89
+ case "add_wait": {
90
+ const i = Number(e.after);
91
+ if (!steps || !steps.items[i])
92
+ throw new Error(`no step ${i}`);
93
+ steps.items.splice(i + 1, 0, doc.createNode({ action: "wait", ms: num(e.ms) }));
94
+ done.push(`Pause ${(num(e.ms) / 1000).toFixed(1)}s after step ${i}`);
95
+ rerecord = true;
96
+ break;
97
+ }
98
+ case "wait_for": {
99
+ const i = Number(e.step);
100
+ const n = step(i);
101
+ if (!n || !steps)
102
+ throw new Error(`no step ${i}`);
103
+ const node = doc.createNode({ action: "waitFor", selector: String(e.selector), timeout: 30000 });
104
+ if (n.get("action") === "wait") {
105
+ steps.items.splice(i, 1, node);
106
+ done.push(`Step ${i} now waits for ${e.selector} instead of a fixed pause`);
107
+ }
108
+ else {
109
+ steps.items.splice(i + 1, 0, node);
110
+ done.push(`Waits for ${e.selector} after step ${i}`);
111
+ }
112
+ rerecord = true;
113
+ break;
114
+ }
115
+ case "set_wait": {
116
+ const n = step(e.step);
117
+ if (!n || n.get("action") !== "wait")
118
+ throw new Error(`step ${e.step} is not a wait`);
119
+ n.set("ms", num(e.ms));
120
+ done.push(`Wait at step ${e.step} → ${(num(e.ms) / 1000).toFixed(1)}s`);
121
+ rerecord = true;
122
+ break;
123
+ }
124
+ case "set_text": {
125
+ const n = step(e.step);
126
+ if (!n || !["type", "fill"].includes(String(n.get("action"))))
127
+ throw new Error(`step ${e.step} does not type`);
128
+ n.set("text", String(e.text));
129
+ done.push(`Step ${e.step} now types “${e.text}”`);
130
+ rerecord = true;
131
+ break;
132
+ }
133
+ case "replace_selector": {
134
+ const n = step(e.step);
135
+ if (!n || !n.has("selector"))
136
+ throw new Error(`step ${e.step} has no selector`);
137
+ n.set("selector", String(e.selector));
138
+ done.push(`Step ${e.step} now targets ${e.selector}`);
139
+ rerecord = true;
140
+ break;
141
+ }
142
+ case "delete_step": {
143
+ const i = Number(e.step);
144
+ if (!steps || !steps.items[i])
145
+ throw new Error(`no step ${i}`);
146
+ const n = steps.items[i];
147
+ const what = typeof n.get === "function" ? `${n.get("action")}${n.has("selector") ? " " + n.get("selector") : ""}` : `step ${i}`;
148
+ steps.items.splice(i, 1);
149
+ done.push(`Removed ${what}`);
150
+ rerecord = true;
151
+ break;
152
+ }
153
+ // Structural edits. Without these the only way to add a click or a
154
+ // scene was rewriting the whole file — which an agent under pressure
155
+ // would rather not do, so it gave up instead (distill-messy, day one).
156
+ // The new step is checked against the schema before it is allowed in.
157
+ case "insert_step": {
158
+ if (!steps)
159
+ throw new Error("manifest has no steps");
160
+ const after = Number(e.after);
161
+ if (!Number.isInteger(after) || after < -1 || after >= steps.items.length)
162
+ throw new Error(`after must be -1 (start) … ${steps.items.length - 1}`);
163
+ const parsedStep = Step.safeParse(e.step);
164
+ if (!parsedStep.success)
165
+ throw new Error("invalid step: " + parsedStep.error.issues.map((i) => `${i.path.join(".") || "step"}: ${i.message}`).join("; "));
166
+ steps.items.splice(after + 1, 0, doc.createNode(parsedStep.data));
167
+ const st = parsedStep.data;
168
+ done.push(`Inserted ${st.action}${st.selector ? " " + st.selector : st.label ? ` "${st.label}"` : ""} as step ${after + 1}`);
169
+ rerecord = true;
170
+ break;
171
+ }
172
+ case "set_step": {
173
+ const i = Number(e.step);
174
+ if (!steps || !steps.items[i])
175
+ throw new Error(`no step ${i}`);
176
+ const parsedStep = Step.safeParse(e.value);
177
+ if (!parsedStep.success)
178
+ throw new Error("invalid step: " + parsedStep.error.issues.map((x) => `${x.path.join(".") || "step"}: ${x.message}`).join("; "));
179
+ steps.items.splice(i, 1, doc.createNode(parsedStep.data));
180
+ const st = parsedStep.data;
181
+ done.push(`Step ${i} is now ${st.action}${st.selector ? " " + st.selector : st.label ? ` "${st.label}"` : ""}`);
182
+ rerecord = true;
183
+ break;
184
+ }
185
+ case "rerecord": {
186
+ done.push("Recording again from scratch");
187
+ rerecord = true;
188
+ break;
189
+ }
190
+ default: skipped.push(`unknown edit ${String(e.op)}`);
191
+ }
192
+ }
193
+ catch (err) {
194
+ skipped.push(`${e.op}: ${err.message}`);
195
+ }
196
+ }
197
+ const yaml = doc.toString();
198
+ const parsed = Manifest.safeParse(YAML.parse(yaml));
199
+ if (!parsed.success)
200
+ throw new Error("edit would make the manifest invalid: " + parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
201
+ if (done.length)
202
+ fs.writeFileSync(file, yaml);
203
+ return { yaml, done, skipped, rerecord };
204
+ }
205
+ /** What the model needs to know about the last take, in plain text with the
206
+ indexes and labels it must use. Secrets never appear (describe() redacts). */
207
+ export function receiptsFor(take, manifestSteps) {
208
+ const lines = [];
209
+ lines.push("Steps (index · action):");
210
+ manifestSteps.forEach((s, i) => {
211
+ let desc = s.action;
212
+ if (s.action === "scene")
213
+ desc = `scene "${s.label}"${s.caption ? ` — caption “${s.caption}”` : ""}`;
214
+ else if (s.action === "wait")
215
+ desc = `wait ${s.ms}ms`;
216
+ else if (s.selector)
217
+ desc = `${s.action} ${s.selector}${s.text && !s.secret ? ` “${s.text.slice(0, 40)}”` : ""}`;
218
+ lines.push(` ${i}: ${desc}`);
219
+ });
220
+ if (take) {
221
+ lines.push("", `Last take: ${(take.duration - take.trimBefore).toFixed(1)}s, ${take.ok ? "all steps passed" : "some steps FAILED"}${take.partial ? ", partial" : ""}.`);
222
+ const fails = take.timeline.filter((t) => !t.ok);
223
+ if (fails.length)
224
+ lines.push("Failed: " + fails.map((t) => `step ${t.index} (${t.summary}) — ${t.error}`).join("; "));
225
+ const scenes = take.timeline.filter((t) => t.action === "scene");
226
+ lines.push("Scene timings: " + scenes.map((s) => `${s.label}@${(s.start - take.trimBefore).toFixed(1)}s`).join(", "));
227
+ }
228
+ return lines.join("\n");
229
+ }