@tandryukha/aidemo 0.8.0 → 0.15.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.
@@ -0,0 +1,640 @@
1
+ /**
2
+ * `aidemo import-trace`: turn what a Playwright run already knows — a
3
+ * `trace.zip` from `context.tracing` / `npx playwright test --trace on`, or a
4
+ * `*.spec.ts` test file — into a draft storyboard. Actions and selectors come
5
+ * from the trace verbatim (the flow is proven to work), scenes are cut at
6
+ * navigations and async payoffs, narration is left as placeholders. No LLM.
7
+ */
8
+
9
+ import { promises as fs } from "node:fs";
10
+ import { basename, extname } from "node:path";
11
+ import { inflateRawSync } from "node:zlib";
12
+
13
+ export interface ImportedAction {
14
+ op: string;
15
+ [k: string]: unknown;
16
+ }
17
+ export interface ImportedScene {
18
+ id: string;
19
+ narration: string;
20
+ actions: ImportedAction[];
21
+ }
22
+ export interface ImportResult {
23
+ source: string;
24
+ kind: "trace" | "test";
25
+ storyboard: Record<string, unknown>;
26
+ /** What was skipped or approximated, for the agent to review. */
27
+ notes: string[];
28
+ steps: number;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Minimal zip reader (stored + deflate entries) — enough for trace.zip.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function readZipEntries(buf: Buffer): Map<string, Buffer> {
36
+ const out = new Map<string, Buffer>();
37
+ // End of central directory record.
38
+ let eocd = -1;
39
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 70000); i--) {
40
+ if (buf.readUInt32LE(i) === 0x06054b50) {
41
+ eocd = i;
42
+ break;
43
+ }
44
+ }
45
+ if (eocd < 0) throw new Error("not a zip file (no end-of-central-directory record)");
46
+ const count = buf.readUInt16LE(eocd + 10);
47
+ let p = buf.readUInt32LE(eocd + 16);
48
+ for (let n = 0; n < count; n++) {
49
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error("zip: bad central directory entry");
50
+ const method = buf.readUInt16LE(p + 10);
51
+ const csize = buf.readUInt32LE(p + 20);
52
+ const nameLen = buf.readUInt16LE(p + 28);
53
+ const extraLen = buf.readUInt16LE(p + 30);
54
+ const commentLen = buf.readUInt16LE(p + 32);
55
+ const local = buf.readUInt32LE(p + 42);
56
+ const name = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8");
57
+ p += 46 + nameLen + extraLen + commentLen;
58
+ if (buf.readUInt32LE(local) !== 0x04034b50) throw new Error("zip: bad local header");
59
+ const lNameLen = buf.readUInt16LE(local + 26);
60
+ const lExtraLen = buf.readUInt16LE(local + 28);
61
+ const start = local + 30 + lNameLen + lExtraLen;
62
+ const raw = buf.subarray(start, start + csize);
63
+ if (method === 0) out.set(name, Buffer.from(raw));
64
+ else if (method === 8) out.set(name, inflateRawSync(raw));
65
+ else throw new Error(`zip: unsupported compression method ${method} for ${name}`);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Selector normalization: Playwright's internal engines → public selectors the
72
+ // storyboard can carry, plus nth/last/frame extraction.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export interface NormalizedTarget {
76
+ selector: string;
77
+ nth?: number;
78
+ last?: boolean;
79
+ /** Selector of the iframe the target lives in (frameLocator chain). */
80
+ frameSelector?: string;
81
+ note?: string;
82
+ }
83
+
84
+ function unquote(s: string): string {
85
+ const m = /^"((?:[^"\\]|\\.)*)"([is]?)$/.exec(s.trim());
86
+ return m ? m[1].replace(/\\"/g, '"') : s.trim();
87
+ }
88
+
89
+ function publicPart(part: string): { sel: string; note?: string } {
90
+ const t = part.trim();
91
+ let m: RegExpExecArray | null;
92
+ if ((m = /^internal:testid=\[([^=]+)="((?:[^"\\]|\\.)*)"s?\]$/.exec(t))) {
93
+ return { sel: `[${m[1]}="${m[2]}"]` };
94
+ }
95
+ if ((m = /^internal:role=(\w+)(?:\[(.*)\])?$/.exec(t))) {
96
+ return { sel: m[2] ? `role=${m[1]}[${m[2]}]` : `role=${m[1]}` };
97
+ }
98
+ if ((m = /^internal:text=(.*)$/.exec(t))) {
99
+ const exact = /"s$/.test(t);
100
+ return { sel: exact ? `text="${unquote(m[1])}"` : `text=${unquote(m[1])}` };
101
+ }
102
+ if ((m = /^internal:has-text=(.*)$/.exec(t))) {
103
+ return { sel: `:has-text("${unquote(m[1])}")` };
104
+ }
105
+ if (/^internal:label=/.test(t)) {
106
+ // Playwright resolves `internal:label=` in a plain locator string and it
107
+ // lands on the labelled CONTROL (verified against page.locator), which is
108
+ // what getByLabel means — keep it verbatim rather than degrading it to a
109
+ // text match on the label element.
110
+ return { sel: t };
111
+ }
112
+ if ((m = /^internal:attr=\[([^=]+)="((?:[^"\\]|\\.)*)"[is]?\]$/.exec(t))) {
113
+ return { sel: `[${m[1]}="${m[2]}"]` };
114
+ }
115
+ if (/^internal:/.test(t)) {
116
+ return { sel: t, note: `kept Playwright-internal selector "${t}" — verify with aidemo probe` };
117
+ }
118
+ return { sel: t };
119
+ }
120
+
121
+ export function normalizeSelector(raw: string): NormalizedTarget {
122
+ const parts = raw.split(/\s*>>\s*/);
123
+ const out: NormalizedTarget = { selector: "" };
124
+ const keep: string[] = [];
125
+ const notes: string[] = [];
126
+ let frameParts: string[] | null = null;
127
+ for (const part of parts) {
128
+ if (part === "internal:control=enter-frame") {
129
+ // Everything before this point located the iframe.
130
+ frameParts = keep.splice(0, keep.length);
131
+ continue;
132
+ }
133
+ const nth = /^nth=(-?\d+)$/.exec(part);
134
+ if (nth) {
135
+ const n = Number(nth[1]);
136
+ if (n < 0) out.last = true;
137
+ else if (n > 0) out.nth = n;
138
+ continue;
139
+ }
140
+ if (/^internal:control=/.test(part)) continue;
141
+ const { sel, note } = publicPart(part);
142
+ keep.push(sel);
143
+ if (note) notes.push(note);
144
+ }
145
+ // A `:has-text()` part chains onto the previous CSS part; other parts join
146
+ // with Playwright's `>>` which page.locator understands. A `:scope…` part is
147
+ // a filter on the located element, not a suffix — it keeps its own `>>`.
148
+ out.selector = keep.reduce(
149
+ (acc, s) =>
150
+ s.startsWith(":") && !s.startsWith(":scope") ? acc + s : acc ? `${acc} >> ${s}` : s,
151
+ ""
152
+ );
153
+ if (frameParts?.length) out.frameSelector = frameParts.join(" >> ");
154
+ if (notes.length) out.note = notes.join("; ");
155
+ return out;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Steps → storyboard
160
+ // ---------------------------------------------------------------------------
161
+
162
+ interface Step {
163
+ method: string;
164
+ selector?: string;
165
+ value?: string;
166
+ key?: string;
167
+ url?: string;
168
+ files?: string[];
169
+ ms?: number;
170
+ /** For dragAndDrop. */
171
+ target?: string;
172
+ option?: string;
173
+ urlIsRegex?: boolean;
174
+ valueIsRegex?: boolean;
175
+ }
176
+
177
+ function stepsFromTrace(entries: Map<string, Buffer>, notes: string[]): Step[] {
178
+ const files = [...entries.keys()].filter((n) => n.endsWith(".trace"));
179
+ if (!files.length) throw new Error("no *.trace entry in the zip — is this a Playwright trace.zip?");
180
+ const steps: Step[] = [];
181
+ for (const f of files) {
182
+ const lines = entries.get(f)!.toString("utf8").split("\n");
183
+ // Calls that threw (a failed locator, a caught timeout) are not part of
184
+ // the flow the test proved — drop them by callId.
185
+ const failed = new Set<string>();
186
+ for (const line of lines) {
187
+ if (!line.startsWith('{"type":"after"')) continue;
188
+ try {
189
+ const ev = JSON.parse(line);
190
+ if (ev.error) failed.add(String(ev.callId));
191
+ } catch {
192
+ /* skip */
193
+ }
194
+ }
195
+ for (const line of lines) {
196
+ if (!line.startsWith("{")) continue;
197
+ let ev: any;
198
+ try {
199
+ ev = JSON.parse(line);
200
+ } catch {
201
+ continue;
202
+ }
203
+ if (ev.type !== "before") continue;
204
+ if (failed.has(String(ev.callId))) {
205
+ notes.push(`dropped ${ev.class}.${ev.method}(${ev.params?.selector ?? ev.params?.url ?? ""}) — it failed in the trace`);
206
+ continue;
207
+ }
208
+ const m = String(ev.method ?? "");
209
+ const p = ev.params ?? {};
210
+ const cls = String(ev.class ?? "");
211
+ if (cls !== "Frame" && cls !== "Page" && cls !== "ElementHandle") continue;
212
+ switch (m) {
213
+ case "goto":
214
+ steps.push({ method: "goto", url: p.url });
215
+ break;
216
+ case "fill":
217
+ case "type":
218
+ case "pressSequentially":
219
+ steps.push({ method: "type", selector: p.selector, value: p.value ?? p.text });
220
+ break;
221
+ case "press":
222
+ steps.push({ method: "press", selector: p.selector, key: p.key });
223
+ break;
224
+ case "keyboardPress":
225
+ steps.push({ method: "press", key: p.key });
226
+ break;
227
+ case "click":
228
+ case "dblclick":
229
+ case "check":
230
+ case "uncheck":
231
+ case "tap":
232
+ steps.push({ method: "click", selector: p.selector });
233
+ break;
234
+ case "hover":
235
+ steps.push({ method: "hover", selector: p.selector });
236
+ break;
237
+ case "selectOption": {
238
+ const o = p.options?.[0] ?? {};
239
+ steps.push({ method: "select", selector: p.selector, option: o.valueOrLabel ?? o.value ?? o.label });
240
+ break;
241
+ }
242
+ case "setInputFiles":
243
+ steps.push({
244
+ method: "upload",
245
+ selector: p.selector,
246
+ files: (p.localPaths ?? p.payloads?.map((x: any) => x.name) ?? []).map((x: string) => basename(x)),
247
+ });
248
+ break;
249
+ case "waitForSelector":
250
+ steps.push({ method: "waitFor", selector: p.selector });
251
+ break;
252
+ case "waitForTimeout":
253
+ steps.push({ method: "pause", ms: Number(p.timeout) || 1000 });
254
+ break;
255
+ case "scrollIntoViewIfNeeded":
256
+ steps.push({ method: "scrollTo", selector: p.selector });
257
+ break;
258
+ case "dragAndDrop":
259
+ steps.push({ method: "drag", selector: p.source, target: p.target });
260
+ break;
261
+ case "goBack":
262
+ steps.push({ method: "back" });
263
+ break;
264
+ case "expect":
265
+ // Assertions: toBeVisible / toHaveText / toContainText / toHaveURL.
266
+ if (p.selector) steps.push({ method: "assert", selector: p.selector, value: p.expectedText?.[0]?.string });
267
+ break;
268
+ default:
269
+ if (/^(waitForLoadState|waitForNavigation|waitForURL|evaluate|screenshot|bringToFront|setViewportSize|title|content|url|isVisible|textContent|innerText|count|boundingBox|focus|blur)/.test(m)) break;
270
+ notes.push(`skipped ${cls}.${m}`);
271
+ }
272
+ }
273
+ }
274
+ return steps;
275
+ }
276
+
277
+ /**
278
+ * Escape a literal for embedding inside a double-quoted selector value. The
279
+ * captured text still carries the spec's own source escapes, so unescape once
280
+ * first; then escape backslashes before quotes — escaping only `"` leaves a
281
+ * trailing `\` producing `"foo\"`, which swallows the closing quote and
282
+ * rewrites the selector.
283
+ */
284
+ function q(v: string): string {
285
+ return v.replace(/\\(.)/g, "$1").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
286
+ }
287
+
288
+ /** Tolerant line-based parse of a Playwright test file (page.* chains). */
289
+ export function stepsFromTest(src: string, notes: string[]): Step[] {
290
+ const steps: Step[] = [];
291
+ // `const cart = page.getByTestId("cart")` … `await cart.click()`. Locators
292
+ // parked in a const are the common shape of a readable spec; without this
293
+ // every use of one was skipped with a note.
294
+ const aliases = new Map<string, string>();
295
+ const str = `(?:'((?:[^'\\\\]|\\\\.)*)'|"((?:[^"\\\\]|\\\\.)*)"|\`((?:[^\`\\\\]|\\\\.)*)\`)`;
296
+ const S = (m: RegExpMatchArray, i: number) => m[i] ?? m[i + 1] ?? m[i + 2] ?? "";
297
+ // Collapse statements onto one line.
298
+ const stmts = src.replace(/\r/g, "").split(/;\s*\n|\n(?=\s*(?:await|expect|test|const|let))/);
299
+ for (const stRaw of stmts) {
300
+ const st = stRaw.replace(/\s+/g, " ").trim();
301
+ let m: RegExpMatchArray | null;
302
+ if ((m = st.match(new RegExp(`page\\.goto\\(\\s*${str}`)))) {
303
+ steps.push({ method: "goto", url: S(m, 1) });
304
+ continue;
305
+ }
306
+ const head = /^(?:await\s+)?(?:const\s+[A-Za-z_$][\w$]*\s*=\s*)?(?:expect\(\s*)?([A-Za-z_$][\w$]*)\b/.exec(st);
307
+ const aliased = head && aliases.get(head[1]);
308
+ if (!aliased && !/\b(page|frame|locator|expect)\b/.test(st)) continue;
309
+ // Locator chain → selector.
310
+ let selector: string | undefined = aliased || undefined;
311
+ let frameSel: string | undefined;
312
+ let nth: string | undefined;
313
+ const chain = [...st.matchAll(new RegExp(`\\.(getByRole|getByTestId|getByText|getByLabel|getByPlaceholder|getByTitle|getByAltText|locator|frameLocator|first|last|nth)\\(\\s*(?:${str}|(\\d+))?\\s*(?:,\\s*\\{([^}]*)\\})?\\s*\\)`, "g"))];
314
+ for (const c of chain) {
315
+ const fn = c[1];
316
+ const arg = c[2] ?? c[3] ?? c[4] ?? c[5] ?? "";
317
+ const opts = c[6] ?? "";
318
+ const name = opts.match(new RegExp(`name\\s*:\\s*${str}`));
319
+ const exact = /exact\s*:\s*true/.test(opts);
320
+ const hasText = opts.match(new RegExp(`hasText\\s*:\\s*${str}`));
321
+ let part = "";
322
+ switch (fn) {
323
+ case "getByRole":
324
+ part = name ? `role=${arg}[name="${q(S(name, 1))}"${exact ? "" : "i"}]` : `role=${arg}`;
325
+ break;
326
+ case "getByTestId":
327
+ part = `[data-testid="${q(arg)}"]`;
328
+ break;
329
+ case "getByText":
330
+ part = exact ? `text="${q(arg)}"` : `text=${arg}`;
331
+ break;
332
+ case "getByLabel":
333
+ // Same engine the trace importer emits: resolves to the control.
334
+ part = `internal:label="${q(arg)}"${exact ? "s" : "i"}`;
335
+ break;
336
+ case "getByPlaceholder":
337
+ part = `[placeholder="${q(arg)}"]`;
338
+ break;
339
+ case "getByTitle":
340
+ part = `[title="${q(arg)}"]`;
341
+ break;
342
+ case "getByAltText":
343
+ part = `[alt="${q(arg)}"]`;
344
+ break;
345
+ case "locator":
346
+ part = arg;
347
+ if (hasText) part += `:has-text("${q(S(hasText, 1))}")`;
348
+ break;
349
+ case "frameLocator":
350
+ frameSel = arg;
351
+ continue;
352
+ case "first":
353
+ continue;
354
+ case "last":
355
+ nth = "-1";
356
+ continue;
357
+ case "nth":
358
+ nth = arg;
359
+ continue;
360
+ }
361
+ selector = selector ? `${selector} >> ${part}` : part;
362
+ }
363
+ if (nth != null && selector) selector += ` >> nth=${nth}`;
364
+ if (frameSel && selector) selector = `${frameSel} >> internal:control=enter-frame >> ${selector}`;
365
+ const term = st.match(new RegExp(`\\.(click|dblclick|check|uncheck|fill|type|pressSequentially|press|hover|selectOption|setInputFiles|waitFor|scrollIntoViewIfNeeded|dragTo|toBeVisible|toHaveText|toContainText|toHaveURL|toHaveValue|toBeEnabled|toHaveCount|toBeChecked|toHaveAttribute|toHaveScreenshot)\\(\\s*(?:${str}|/((?:[^/\\\\]|\\\\.)+)/[a-z]*)?`));
366
+ if (!term) {
367
+ if (/page\.waitForTimeout\((\d+)/.test(st)) {
368
+ steps.push({ method: "pause", ms: Number(/page\.waitForTimeout\((\d+)/.exec(st)![1]) });
369
+ } else if (/page\.goBack\(/.test(st)) steps.push({ method: "back" });
370
+ else if (/\bpage\.keyboard\.press\(/.test(st)) {
371
+ const k = st.match(new RegExp(`keyboard\\.press\\(\\s*${str}`));
372
+ if (k) steps.push({ method: "press", key: S(k, 1) });
373
+ } else if (selector) {
374
+ const decl = /^const\s+([A-Za-z_$][\w$]*)\s*=/.exec(st);
375
+ if (decl) aliases.set(decl[1], selector);
376
+ else notes.push(`skipped: ${st.slice(0, 80)}`);
377
+ }
378
+ continue;
379
+ }
380
+ const regexArg = term[5];
381
+ const arg = S(term, 2);
382
+ switch (term[1]) {
383
+ case "click":
384
+ case "dblclick":
385
+ case "check":
386
+ case "uncheck":
387
+ steps.push({ method: "click", selector });
388
+ break;
389
+ case "fill":
390
+ case "type":
391
+ case "pressSequentially":
392
+ steps.push({ method: "type", selector, value: arg });
393
+ break;
394
+ case "press":
395
+ steps.push({ method: "press", selector, key: arg });
396
+ break;
397
+ case "hover":
398
+ steps.push({ method: "hover", selector });
399
+ break;
400
+ case "selectOption":
401
+ steps.push({ method: "select", selector, option: arg });
402
+ break;
403
+ case "setInputFiles":
404
+ steps.push({ method: "upload", selector, files: [basename(arg)] });
405
+ break;
406
+ case "waitFor":
407
+ steps.push({ method: "waitFor", selector });
408
+ break;
409
+ case "scrollIntoViewIfNeeded":
410
+ steps.push({ method: "scrollTo", selector });
411
+ break;
412
+ case "dragTo": {
413
+ const to = [...st.matchAll(new RegExp(`\\.(?:locator|getByTestId|getByText)\\(\\s*${str}`, "g"))];
414
+ const last = to[to.length - 1];
415
+ steps.push({ method: "drag", selector, target: last ? S(last, 1) : undefined });
416
+ break;
417
+ }
418
+ case "toHaveURL":
419
+ steps.push({ method: "assert", url: regexArg ?? arg, urlIsRegex: !!regexArg });
420
+ break;
421
+ case "toBeVisible":
422
+ case "toBeEnabled":
423
+ case "toHaveCount":
424
+ if (selector) steps.push({ method: "assert", selector });
425
+ break;
426
+ case "toBeChecked":
427
+ // The storyboard's `assert` proves an element is THERE, so push the
428
+ // checked-ness into the selector itself (`:scope` filters the located
429
+ // element rather than its descendants).
430
+ if (selector) steps.push({ method: "assert", selector: `${selector} >> :scope:checked` });
431
+ break;
432
+ case "toHaveAttribute": {
433
+ if (!selector) break;
434
+ const attrs = [...st.matchAll(new RegExp(`toHaveAttribute\\(\\s*${str}\\s*(?:,\\s*${str})?`, "g"))][0];
435
+ const attr = attrs ? S(attrs, 1) : "";
436
+ const val = attrs ? S(attrs, 4) : "";
437
+ if (attr && val && !regexArg) {
438
+ steps.push({
439
+ method: "assert",
440
+ selector: `${selector} >> :scope[${attr}="${q(val)}"]`,
441
+ });
442
+ } else if (attr) {
443
+ steps.push({ method: "assert", selector: `${selector} >> :scope[${attr}]` });
444
+ }
445
+ break;
446
+ }
447
+ case "toHaveScreenshot":
448
+ // A pixel baseline has no place in a demo; the beat it guarded does.
449
+ notes.push(
450
+ `toHaveScreenshot() dropped — it asserts pixels, not a story beat; add a \`still\` marker there if you want the frame`
451
+ );
452
+ break;
453
+ case "toHaveText":
454
+ case "toContainText":
455
+ case "toHaveValue":
456
+ if (selector) steps.push({ method: "assert", selector, value: regexArg ?? arg, valueIsRegex: !!regexArg });
457
+ break;
458
+ }
459
+ }
460
+ return steps;
461
+ }
462
+
463
+ function targetOf(sel: string, frames: Record<string, string>, notes: string[]): Record<string, unknown> {
464
+ const n = normalizeSelector(sel);
465
+ const t: Record<string, unknown> = { selector: n.selector };
466
+ if (n.nth != null) t.nth = n.nth;
467
+ if (n.last) t.last = true;
468
+ if (n.frameSelector) {
469
+ let name = Object.keys(frames).find((k) => frames[k] === n.frameSelector);
470
+ if (!name) {
471
+ name = `frame${Object.keys(frames).length + 1}`;
472
+ frames[name] = n.frameSelector;
473
+ }
474
+ t.frame = name;
475
+ }
476
+ if (n.note) notes.push(n.note);
477
+ return t;
478
+ }
479
+
480
+ function escapeRe(s: string): string {
481
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
482
+ }
483
+
484
+ function labelFor(step: Step): string {
485
+ const sel = step.selector ?? "";
486
+ const m = /name="([^"]+)"|text="?([^">]+)"?|data-testid="?([\w-]+)"?|#([\w-]+)/.exec(sel);
487
+ return (m && (m[1] || m[2] || m[3] || m[4])) || sel.slice(0, 40);
488
+ }
489
+
490
+ /** Cut steps into scenes: at every goto, and before a click whose payoff is an explicit wait/assert. */
491
+ export function storyboardFromSteps(name: string, source: string, steps: Step[], notes: string[]): Record<string, unknown> {
492
+ const frames: Record<string, string> = {};
493
+ const scenes: ImportedScene[] = [];
494
+ let cur: ImportedScene | null = null;
495
+ const open = (label: string) => {
496
+ if (cur && cur.actions.length) {
497
+ cur.actions.push({ op: "pause", ms: 1400 });
498
+ scenes.push(cur);
499
+ }
500
+ const id = `s${scenes.length + 1}-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 20) || "step"}`;
501
+ cur = { id, narration: `<narrate: ${label}>`, actions: [] };
502
+ };
503
+ const conv = (s: Step): ImportedAction | null => {
504
+ switch (s.method) {
505
+ case "goto":
506
+ return { op: "goto", url: s.url };
507
+ case "type":
508
+ return { op: "type", target: targetOf(s.selector!, frames, notes), text: s.value ?? "" };
509
+ case "press":
510
+ return { op: "press", key: s.key ?? "Enter" };
511
+ case "click":
512
+ return { op: "click", target: targetOf(s.selector!, frames, notes) };
513
+ case "hover":
514
+ return { op: "hover", target: targetOf(s.selector!, frames, notes) };
515
+ case "select":
516
+ return { op: "select", target: targetOf(s.selector!, frames, notes), value: s.option ?? "" };
517
+ case "upload":
518
+ notes.push(`upload: copy ${s.files?.join(", ") || "the file"} into input/ and point files[] at it`);
519
+ return { op: "upload", target: targetOf(s.selector!, frames, notes), files: (s.files ?? []).map((f) => `input/${f}`) };
520
+ case "waitFor":
521
+ return { op: "waitForWidget", target: targetOf(s.selector!, frames, notes), label: "thinking", timeoutMs: 30000 };
522
+ case "pause":
523
+ return { op: "pause", ms: Math.min(s.ms ?? 1000, 4000) };
524
+ case "scrollTo":
525
+ return { op: "scrollTo", target: targetOf(s.selector!, frames, notes), easing: "smooth" };
526
+ case "drag":
527
+ return {
528
+ op: "drag",
529
+ target: targetOf(s.selector!, frames, notes),
530
+ to: s.target ? { target: targetOf(s.target, frames, notes) } : { x: 0, y: 0 },
531
+ };
532
+ case "back":
533
+ return { op: "back" };
534
+ case "assert": {
535
+ const a: ImportedAction = { op: "assert" };
536
+ if (s.selector) a.target = targetOf(s.selector, frames, notes);
537
+ if (s.value) a.textMatches = s.valueIsRegex ? s.value : escapeRe(s.value);
538
+ if (s.url) a.url = s.urlIsRegex ? s.url : escapeRe(s.url);
539
+ if (!a.target && !a.url) return null;
540
+ return a;
541
+ }
542
+ }
543
+ return null;
544
+ };
545
+ for (let i = 0; i < steps.length; i++) {
546
+ const s = steps[i];
547
+ const next = steps[i + 1];
548
+ const scene = cur as ImportedScene | null;
549
+ if (s.method === "goto") open(`open ${s.url}`);
550
+ else if (!scene) open("start");
551
+ else if (s.method === "click" && next && (next.method === "waitFor" || next.method === "assert") && scene.actions.length > 0) {
552
+ open(labelFor(s));
553
+ }
554
+ const a = conv(s);
555
+ if (!a) continue;
556
+ const at = cur as unknown as ImportedScene;
557
+ at.actions.push(a);
558
+ if (s.method === "goto") at.actions.push({ op: "pause", ms: 1200 });
559
+ }
560
+ const tail = cur as unknown as ImportedScene | null;
561
+ if (tail && tail.actions.length) {
562
+ tail.actions.push({ op: "pause", ms: 1600 });
563
+ scenes.push(tail);
564
+ }
565
+ return {
566
+ _README:
567
+ `Draft imported from ${basename(source)} — the actions and selectors are the test's own (they already pass). ` +
568
+ "Write the narration (one idea per scene, ~2.5 words/s), merge or split scenes so each carries one beat, " +
569
+ "and run `aidemo probe` — then `render`.",
570
+ title: name,
571
+ targetLengthSeconds: Math.max(30, Math.min(90, scenes.length * 9)),
572
+ video: { width: 1280, height: 720 },
573
+ ...(Object.keys(frames).length ? { frames } : {}),
574
+ voice: {
575
+ voiceId: "marin",
576
+ instructions: "Confident, friendly founder. Clear and warm, brisk but not rushed.",
577
+ speed: 1.05,
578
+ },
579
+ zoom: { scale: 1.55, easeMs: 600, holdMs: 1700 },
580
+ intro: { title: name, subtitle: "<one-line value prop>", durationMs: 2600 },
581
+ outro: { title: "<call to action>", subtitle: "<your-domain.example>", durationMs: 2600 },
582
+ scenes,
583
+ ...(notes.length ? { _notes: [...new Set(notes)] } : {}),
584
+ };
585
+ }
586
+
587
+ export async function importTrace(file: string, name: string): Promise<ImportResult> {
588
+ const notes: string[] = [];
589
+ const ext = extname(file).toLowerCase();
590
+ let steps: Step[];
591
+ let kind: ImportResult["kind"];
592
+ if (ext === ".zip") {
593
+ kind = "trace";
594
+ steps = stepsFromTrace(readZipEntries(await fs.readFile(file)), notes);
595
+ } else if ([".ts", ".js", ".mjs", ".mts", ".tsx", ".jsx"].includes(ext)) {
596
+ kind = "test";
597
+ steps = stepsFromTest(await fs.readFile(file, "utf8"), notes);
598
+ } else {
599
+ throw new Error(`import-trace: expected a Playwright trace.zip or a test file (.ts/.js), got ${basename(file)}`);
600
+ }
601
+ if (!steps.length) throw new Error(`import-trace: no browser actions found in ${basename(file)}`);
602
+ const storyboard = storyboardFromSteps(name, file, steps, notes);
603
+ return { source: file, kind, storyboard, notes: [...new Set(notes)], steps: steps.length };
604
+ }
605
+
606
+ import { Project } from "./project.js";
607
+ import { exists, ok } from "./util.js";
608
+
609
+ /** Write the imported draft as demos/<name>/ (storyboard + a short brief). */
610
+ export async function scaffoldImported(
611
+ baseDir: string,
612
+ name: string,
613
+ result: ImportResult,
614
+ opts: { force?: boolean } = {}
615
+ ): Promise<string> {
616
+ const project = new Project(`${baseDir}/demos/${name}`);
617
+ if ((await exists(project.storyboardPath)) && !opts.force) {
618
+ throw new Error(`${project.storyboardPath} exists — pass --force to overwrite`);
619
+ }
620
+ await project.ensureDirs();
621
+ await fs.writeFile(project.storyboardPath, JSON.stringify(result.storyboard, null, 2) + "\n");
622
+ const brief = `# Demo Brief — ${name}
623
+
624
+ Imported from \`${basename(result.source)}\` (${result.kind}, ${result.steps} step(s)).
625
+ The storyboard's actions are the test's own; write the narration and cut the
626
+ scenes to one beat each, then \`aidemo probe\`.
627
+ ${result.notes.length ? `\nReview notes:\n${result.notes.map((n) => `- ${n}`).join("\n")}\n` : ""}
628
+ ## Product
629
+ <your product>
630
+
631
+ ## Demo goal
632
+ <the flow the test proves, told as a story>
633
+
634
+ ## Audience / Tone / CTA
635
+ <fill in>
636
+ `;
637
+ await fs.writeFile(project.p("input", "brief.md"), brief);
638
+ ok(`imported ${result.steps} step(s) → demos/${name}/ (${(result.storyboard.scenes as unknown[]).length} scene(s))`);
639
+ return project.dir;
640
+ }