retake-demos 0.2.2 → 0.4.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/describe.js CHANGED
@@ -25,16 +25,34 @@ import YAML from "yaml";
25
25
  import { Manifest } from "./manifest.js";
26
26
  import { digest } from "./digest.js";
27
27
  export function loadDotenv(root, files = [".env"]) {
28
+ // One parser for every reader of .env (see env.ts), so a password written
29
+ // by the window or `retake secret` is typed back exactly as entered.
28
30
  for (const name of files) {
29
- const f = path.join(root, name);
30
- if (!fs.existsSync(f))
31
+ for (const [k, v] of Object.entries(parseEnvText(root, name)))
32
+ if (process.env[k] === undefined)
33
+ process.env[k] = v;
34
+ }
35
+ }
36
+ function parseEnvText(root, name) {
37
+ const f = path.join(root, name);
38
+ if (!fs.existsSync(f))
39
+ return {};
40
+ const out = {};
41
+ for (const raw of fs.readFileSync(f, "utf8").split(/\r?\n/)) {
42
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
43
+ if (!m)
31
44
  continue;
32
- for (const line of fs.readFileSync(f, "utf8").split("\n")) {
33
- const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
34
- if (m && process.env[m[1]] === undefined)
35
- process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
45
+ const v = m[2].trim();
46
+ if (v.startsWith('"') && v.endsWith('"')) {
47
+ try {
48
+ out[m[1]] = JSON.parse(v);
49
+ continue;
50
+ }
51
+ catch { /* literal */ }
36
52
  }
53
+ out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
37
54
  }
55
+ return out;
38
56
  }
39
57
  function cliPath(bin) {
40
58
  const home = os.homedir();
@@ -293,11 +311,13 @@ export async function proposeEdits(input) {
293
311
  // --- drafting ---------------------------------------------------------------
294
312
  const SCHEMA_DOC = `
295
313
  Manifest YAML fields:
296
- name (kebab-case), title, url, preset: post-landscape, waitForSelector (selector present when page is ready),
297
- camera: auto, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
298
- Do NOT set viewport, cursor, chrome, background, fps, crf or captions — the preset handles quality.
314
+ name (kebab-case), title, url, mode: demo, preset: post-landscape, waitForSelector (selector present when page is ready),
315
+ camera: static, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
316
+ NEVER set viewport. The preset decides the size and every take at a preset comes out identical; setting viewport makes this demo a different shape from every other one and players letterbox it. Same for cursor, chrome, background, fps, crf and captions — the preset handles all of it.
317
+ Always set mode: demo — this is a demo, so nothing goes in the frame but the product. Never emit intro or outro cards, music, callouts or voiceover: those belong to mode: launch, which only a person can ask for.
299
318
  Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
300
319
  {action: scene, label, caption, camera?: static | {focus: selector, zoom: 1.25}} a named beat; caption is burned in until the next scene (under 60 chars). Set camera: static on every scene unless the thing being shown is genuinely too small to read at full frame — a calm, still demo reads as more real than a zooming one. If the person asked for zooms (or stillness), that wins.
320
+ {action: select, selector, value} choose in a <select> — value or the visible label, whichever the page uses
301
321
  {action: wait, ms}
302
322
  {action: click, selector, zoom: 1.3} zoom is optional
303
323
  {action: type, selector, text, delay: 30}
@@ -307,7 +327,7 @@ Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
307
327
  {action: keyboard, key}
308
328
  {action: waitFor, selector, timeout: 30000} wait for something to appear (use after actions that trigger async work)
309
329
  {action: evaluate, script}
310
- Rules: never invent field names or add "?" to keys; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera.
330
+ Rules: never invent field names or add "?" to keys; write mode: demo; no cards, music, callouts or voiceover; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera; write a caption ONLY where it says something the screen cannot (never narrate a visible click), and hold the payoff in ONE frame rather than moving between tabs or views to show its parts separately.
311
331
  `;
312
332
  /** The person's standing taste — demos/style.md, written once, read by every
313
333
  draft. "No zooms, cursor visible, captions plain" should not need repeating. */
package/dist/dryrun.js CHANGED
@@ -8,13 +8,74 @@
8
8
  */
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import { runFileOrCommandSeed, resolvePoint, dragPoints } from "./record.js";
11
+ import { applyPageSetup, authState, dragPoints, gateOnApp, resolvePoint, runEvaluateSeed, runFileOrCommandSeed } from "./record.js";
12
12
  import { chromium } from "playwright";
13
13
  import { expandEnv, resolve } from "./manifest.js";
14
+ async function elInfo(page, selector, timeout) {
15
+ try {
16
+ const h = await page.locator(selector).first().elementHandle({ timeout });
17
+ if (!h)
18
+ return null;
19
+ const info = await h.evaluate((el) => {
20
+ const e = el;
21
+ return {
22
+ tag: e.tagName.toLowerCase(),
23
+ type: (e.type ?? "").toLowerCase(),
24
+ disabled: !!e.disabled || e.getAttribute("aria-disabled") === "true",
25
+ readOnly: !!e.readOnly,
26
+ editable: e.isContentEditable,
27
+ };
28
+ });
29
+ await h.dispose();
30
+ return info;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** Does this action make sense on this element? Returns the complaint, or null.
37
+ *
38
+ * This is the check that pays for itself: an action that cannot work on the
39
+ * element it names is knowable before a single frame is recorded, and finding
40
+ * it out during a take costs the whole take. So the message names the fix, not
41
+ * just the symptom — a report that says "use `action: select`" ends the
42
+ * problem, one that says "element is not an <input>" starts an investigation. */
43
+ export function actionFits(action, el) {
44
+ const NOT_TEXT = new Set(["button", "submit", "reset", "checkbox", "radio", "file", "image", "range", "color"]);
45
+ const is = (t) => el.tag === t;
46
+ if (action === "type" || action === "fill") {
47
+ if (is("select"))
48
+ return `\`${action}\` cannot be used on a <select> — use \`action: select\` with \`value: "<the option>"\``;
49
+ if (is("input") && el.type === "file")
50
+ return `\`${action}\` cannot be used on a file input — use \`action: upload\` with \`file:\``;
51
+ if (is("input") && NOT_TEXT.has(el.type))
52
+ return `\`${action}\` cannot be used on <input type="${el.type}"> — it holds no text; \`action: click\` is probably what you mean`;
53
+ if (!is("input") && !is("textarea") && !el.editable)
54
+ return `\`${action}\` needs a text field, but this is a <${el.tag}> that is not editable — if it is a custom editor, target the inner element that accepts typing`;
55
+ if (el.disabled)
56
+ return `\`${action}\` cannot type into a disabled <${el.tag}> — something earlier has to enable it first`;
57
+ if (el.readOnly)
58
+ return `\`${action}\` cannot type into a read-only <${el.tag}>`;
59
+ }
60
+ if (action === "select" && !is("select")) {
61
+ return `\`select\` only works on a real <select>, and this is a <${el.tag}> — for a custom dropdown, \`click\` it and then \`click\` the option`;
62
+ }
63
+ if (action === "upload" && !(is("input") && el.type === "file")) {
64
+ return `\`upload\` needs <input type="file">, and this is a <${el.tag}>${el.type && el.type !== el.tag ? ` type="${el.type}"` : ""}`;
65
+ }
66
+ if (action === "click" && el.disabled) {
67
+ return `this <${el.tag}> is disabled right now — the click would do nothing; wait for whatever enables it (\`action: waitFor\`) before clicking`;
68
+ }
69
+ return null;
70
+ }
14
71
  export async function dryRun(m, manifestDir, log, opts = {}) {
15
72
  const q = resolve(m);
16
73
  const lines = [];
17
74
  let failures = 0;
75
+ /** What the page actually laid out at — the width its breakpoints saw. */
76
+ let layoutWidth = null;
77
+ // Failure pictures land beside the demo's other output, not in the cwd.
78
+ const shotDir = path.join(opts.outRoot ?? "outputs", m.name);
18
79
  // Seeds run here too (file and command kinds — the cheap, out-of-page
19
80
  // ones). Without them dry checks a different app state than run will,
20
81
  // and a selector failure that is really a state difference looks like a
@@ -28,11 +89,23 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
28
89
  log("dry: seeds skipped (--no-seed) — app state may not match what run will see");
29
90
  }
30
91
  const browser = await chromium.launch({ headless: true });
92
+ // Start from the same session run would. Without this, a demo behind a
93
+ // login was dry-run logged OUT — every selector "missing", or worse, a
94
+ // green dry against a marketing page and a failed take against the app.
95
+ const auth = authState(m, manifestDir);
96
+ if (auth.path)
97
+ log(auth.fresh ? `dry: reusing session ${path.relative(process.cwd(), auth.path)}` : `dry: no fresh session — auth.setup will have to sign in`);
31
98
  const context = await browser.newContext({
32
99
  viewport: q.viewport,
33
100
  colorScheme: m.colorScheme,
34
101
  ...(m.reducedMotion ? { reducedMotion: "reduce" } : {}),
102
+ ...(auth.fresh && auth.path ? { storageState: auth.path } : {}),
35
103
  });
104
+ // Whatever the recorder injects, dry injects — one function, so the two
105
+ // cannot drift apart again. (Measured, for the record: `html{zoom}` does
106
+ // NOT move an app's media queries, which is why the layout width reported
107
+ // below is asked of the page rather than computed as width÷scale.)
108
+ await applyPageSetup(context, m, q);
36
109
  const stubs = new Map();
37
110
  const arm = async (d) => {
38
111
  const key = `${d.method ?? "ANY"} ${d.url}`;
@@ -56,6 +129,20 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
56
129
  // go network-idle, and the manifest's waitForSelector is the real gate.
57
130
  await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 60000 });
58
131
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
132
+ // `evaluate` seeds write into the page itself — the panel that only
133
+ // exists once some state is seeded. Dry skipped them, so dry and run were
134
+ // literally looking at different apps; that was one of three lost takes
135
+ // in the field report.
136
+ if (opts.seed !== false) {
137
+ for (const sd of m.seed)
138
+ if (sd.kind === "evaluate")
139
+ await runEvaluateSeed(page, sd, manifestDir, log);
140
+ }
141
+ // The manifest's own readiness gate, with run's wrong-app diagnosis. A dry
142
+ // run that starts stepping before the app has booted reports selector
143
+ // failures that are really a race.
144
+ await gateOnApp(page, m, shotDir);
145
+ layoutWidth = await page.evaluate(() => document.documentElement.clientWidth).catch(() => null);
59
146
  for (const s of [...(m.auth?.setup ?? []), ...m.setup])
60
147
  await run(s, true);
61
148
  for (const [i, s] of m.steps.entries())
@@ -69,6 +156,16 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
69
156
  const tag = isSetup ? "setup" : String(index).padStart(3, "0");
70
157
  const short = 8000; // long enough for a click that navigates, short enough to stay fast
71
158
  try {
159
+ // Before doing anything: does this action fit the element it names? A
160
+ // mismatch is the expensive kind of failure — the selector resolves, dry
161
+ // passes, and the take dies on it minutes into a recording.
162
+ const sel = step.selector;
163
+ if (sel && ["type", "fill", "select", "upload", "click"].includes(step.action)) {
164
+ const el = await elInfo(page, sel, short);
165
+ const complaint = el && actionFits(step.action, el);
166
+ if (complaint)
167
+ throw new Error(complaint);
168
+ }
72
169
  // A point step (click/hover `at`, or a drag) is verified by resolving
73
170
  // its target's rect in the page — the same way the recorder will.
74
171
  const point = async (t) => {
@@ -117,7 +214,7 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
117
214
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
118
215
  break;
119
216
  case "waitFor":
120
- await page.waitForSelector(step.selector, { timeout: Math.min(step.timeout ?? 15000, 15000) });
217
+ await page.waitForSelector(step.selector, { state: step.gone ? "hidden" : "visible", timeout: Math.min(step.timeout ?? 15000, 15000) });
121
218
  break;
122
219
  // noWaitAfter: a click that submits a form starts a navigation, and
123
220
  // waiting for the element to settle afterwards reports a false failure
@@ -138,6 +235,19 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
138
235
  case "hover":
139
236
  await page.locator(step.selector).first().hover({ timeout: short });
140
237
  break;
238
+ case "select": {
239
+ // Resolving is not enough: an option that does not exist is the
240
+ // whole failure mode, so dry proves the value is selectable.
241
+ const sel = page.locator(step.selector).first();
242
+ await sel.waitFor({ timeout: short });
243
+ try {
244
+ await sel.selectOption(step.value, { timeout: short });
245
+ }
246
+ catch {
247
+ await sel.selectOption({ label: step.value }, { timeout: short });
248
+ }
249
+ break;
250
+ }
141
251
  case "scroll":
142
252
  if (step.to)
143
253
  await page.locator(step.to).first().boundingBox({ timeout: short });
@@ -160,6 +270,9 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
160
270
  case "wait":
161
271
  await page.waitForTimeout(isSetup ? step.ms : Math.min(step.ms, 400));
162
272
  break;
273
+ case "callout":
274
+ await point(step.selector ?? step.at);
275
+ break;
163
276
  default: break; // scene, screenshot, zoom, download: nothing to verify
164
277
  }
165
278
  if (step.pauseAfter)
@@ -176,6 +289,16 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
176
289
  where = `\n on ${page.url()} — “${text}”`;
177
290
  }
178
291
  catch { /* page may be gone */ }
292
+ // Text alone cannot say "another element is covering the button" or
293
+ // "the layout is not what you think". A picture can, and dry is the
294
+ // cheap pass — it should hand over more than run's does, not less.
295
+ try {
296
+ const shot = path.join(shotDir, `dry-failed-${String(failures).padStart(2, "0")}.png`);
297
+ fs.mkdirSync(shotDir, { recursive: true });
298
+ await page.screenshot({ path: shot, fullPage: true });
299
+ where += `\n picture: ${path.relative(process.cwd(), shot)}`;
300
+ }
301
+ catch { /* not fatal */ }
179
302
  const line = `✗ [${tag}] ${describe(step)} — ${why}${where}`;
180
303
  lines.push(line);
181
304
  log(line);
@@ -183,8 +306,68 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
183
306
  }
184
307
  const ok = failures === 0;
185
308
  log(ok ? `dry run: all ${m.steps.length} steps resolved` : `dry run: ${failures} step(s) would fail`);
309
+ // A preset's width is VIDEO pixels: at scale 2 a 1920 preset lays the page
310
+ // out at 960 CSS px, under most desktop breakpoints, so a responsive app
311
+ // quietly serves its MOBILE layout and the steps are looking for fields that
312
+ // are not there. It reads as a pile of broken selectors, and costs a whole
313
+ // take to work out. Only asked when something actually failed, and only
314
+ // reported when the wider page proves it.
315
+ if (!ok) {
316
+ // Measured, not computed: `zoom` does not move an app's media queries, so
317
+ // width÷scale is not the number the page's breakpoints saw. Ask the page.
318
+ const cssWidth = layoutWidth ?? q.viewport.width;
319
+ if (cssWidth < 1100) {
320
+ const missing = lines.map((l) => /✗ \[[^\]]+\] \w+ (.+?) —/.exec(l)?.[1]).filter((x) => !!x);
321
+ const found = await resolvesWiderThan(m, missing, cssWidth, manifestDir);
322
+ if (found.length) {
323
+ const note = `⚠ ${found.length} of these selector(s) DO exist when the page is 1280 CSS px wide, but not at ${cssWidth}px.\n` +
324
+ ` This take lays the page out at ${cssWidth} CSS px, which is below this app's desktop breakpoint — you are filming its mobile layout.\n` +
325
+ ` Fix: give the manifest a wider \`viewport\`, or write the steps against the layout you are actually filming.\n` +
326
+ ` Resolved wider: ${found.slice(0, 4).join(", ")}${found.length > 4 ? ` … +${found.length - 4}` : ""}`;
327
+ lines.push(note);
328
+ log(note);
329
+ }
330
+ }
331
+ }
186
332
  return { ok, lines, failures };
187
333
  }
334
+ /** Load the same page at a desktop width and report which of these selectors
335
+ resolve there. Proof, not a guess: it is only worth telling someone their
336
+ preset is filming the mobile layout if the wider page really does have the
337
+ thing they were looking for. */
338
+ async function resolvesWiderThan(m, selectors, narrowPx, manifestDir) {
339
+ const unique = [...new Set(selectors)].filter((s) => s && !s.startsWith("http"));
340
+ if (!unique.length || narrowPx >= 1280)
341
+ return [];
342
+ const browser = await chromium.launch({ headless: true });
343
+ try {
344
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, colorScheme: m.colorScheme });
345
+ const page = await ctx.newPage();
346
+ for (const d of m.stub) {
347
+ await ctx.route(d.url, async (route) => {
348
+ const body = d.json !== undefined ? JSON.stringify(d.json) : d.from ? fs.readFileSync(path.resolve(manifestDir, expandEnv(d.from)), "utf8") : "{}";
349
+ await route.fulfill({ status: d.status ?? 200, contentType: d.contentType, body });
350
+ });
351
+ }
352
+ await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 30000 });
353
+ await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => { });
354
+ const found = [];
355
+ for (const sel of unique) {
356
+ try {
357
+ if (await page.locator(sel).first().isVisible({ timeout: 1500 }))
358
+ found.push(sel);
359
+ }
360
+ catch { /* still missing */ }
361
+ }
362
+ return found;
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ finally {
368
+ await browser.close().catch(() => { });
369
+ }
370
+ }
188
371
  function describe(s) {
189
372
  if ("selector" in s && s.selector)
190
373
  return `${s.action} ${s.selector}`;
package/dist/env.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The workspace `.env` — where an app's demo-account credentials live.
3
+ *
4
+ * Three things write it: the person (by hand, or `retake secret NAME`), the
5
+ * window (when an agent asks for a secret by name), and Settings. None of
6
+ * them ever hands a value to a model: the agent learns that `APP_PASSWORD`
7
+ * is *set*, and references it as `${APP_PASSWORD}` with `secret: true`.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
12
+ /** Parse KEY=value lines (quotes stripped, comments and blanks ignored). */
13
+ export function readEnvFile(root) {
14
+ const file = path.join(root, ".env");
15
+ if (!fs.existsSync(file))
16
+ return {};
17
+ const out = {};
18
+ for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
19
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
20
+ if (!m)
21
+ continue;
22
+ const v = m[2].trim();
23
+ // Double-quoted values are JSON strings (that is how writeEnvFile writes
24
+ // anything awkward); single-quoted are literal; bare is bare.
25
+ if (v.startsWith('"') && v.endsWith('"')) {
26
+ try {
27
+ out[m[1]] = JSON.parse(v);
28
+ continue;
29
+ }
30
+ catch { /* fall through */ }
31
+ }
32
+ out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
33
+ }
34
+ return out;
35
+ }
36
+ /** Set (or, with "", remove) keys in .env, leaving every other line alone.
37
+ Returns the keys actually written. Also updates process.env so the
38
+ calling process sees the new values without a restart. */
39
+ export function writeEnvFile(root, set) {
40
+ const file = path.join(root, ".env");
41
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n") : [];
42
+ const written = [];
43
+ for (const [k, v] of Object.entries(set)) {
44
+ if (v === undefined)
45
+ continue;
46
+ const i = lines.findIndex((l) => l.startsWith(k + "="));
47
+ // Quote when the value has anything a shell-ish parser would trip on.
48
+ const line = `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`;
49
+ if (v === "") {
50
+ if (i >= 0)
51
+ lines.splice(i, 1);
52
+ delete process.env[k];
53
+ }
54
+ else {
55
+ if (i >= 0)
56
+ lines[i] = line;
57
+ else
58
+ lines.push(line);
59
+ process.env[k] = v;
60
+ }
61
+ written.push(k);
62
+ }
63
+ fs.writeFileSync(file, lines.filter((l, i, a) => l !== "" || i < a.length - 1).join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
64
+ // `mode` above only applies to a new file; an existing .env keeps whatever
65
+ // it had. Credentials went in, so: owner-only, every time.
66
+ try {
67
+ fs.chmodSync(file, 0o600);
68
+ }
69
+ catch { /* windows */ }
70
+ return written;
71
+ }
72
+ /** Which of these names are not set (missing or blank) in .env + the environment. */
73
+ export function missingSecrets(root, names) {
74
+ const file = readEnvFile(root);
75
+ return names.filter((n) => !(process.env[n]?.trim() || file[n]?.trim()));
76
+ }