@humanjs/playwright 0.6.0 → 0.7.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/index.d.cts CHANGED
@@ -163,6 +163,28 @@ interface CaptureResult {
163
163
  cleanup(): Promise<void>;
164
164
  }
165
165
 
166
+ /** Options for {@link generatePlaywrightTest} / `Recording.toPlaywright`. */
167
+ interface PlaywrightTestOptions {
168
+ /**
169
+ * Keep recorded `sleep()` pauses. Default `false` — a test shouldn't carry
170
+ * human-timing waits (slow + flaky in CI; Playwright auto-waits instead).
171
+ */
172
+ readonly keepSleeps?: boolean;
173
+ /** Test title. Defaults to the recording's name, else `'recorded session'`. */
174
+ readonly title?: string;
175
+ /**
176
+ * Wrap actions in `test.step(...)` groups — a new step per navigation — so
177
+ * they show as collapsible sections in the HTML report / trace. Default false.
178
+ */
179
+ readonly steps?: boolean;
180
+ /**
181
+ * When all `goto`s share one origin, emit them as relative paths and add a
182
+ * note to set `use.baseURL` in the Playwright config (portable across
183
+ * environments). Default false — absolute URLs that run without any config.
184
+ */
185
+ readonly baseUrl?: boolean;
186
+ }
187
+
166
188
  /**
167
189
  * Encoding quality preset. Picks the per-frame capture quality + the
168
190
  * ffmpeg encode settings used to assemble them into a video.
@@ -212,10 +234,18 @@ interface TimelineEvent {
212
234
  readonly durationMs: number;
213
235
  /** Error message, present only if the action threw. */
214
236
  readonly error?: string;
237
+ /**
238
+ * For `type` / `paste`: the actual text written, captured when
239
+ * `captureInputs` is on (the default). Omitted when capture is off or the
240
+ * target is a password field (always masked). Flows into exported code.
241
+ */
242
+ readonly inputValue?: string;
215
243
  }
216
244
  /** Structured action timeline of a recording. */
217
245
  interface Timeline {
218
246
  readonly version: 1;
247
+ /** Optional label for the recording (used as the generated test's title). */
248
+ readonly name?: string;
219
249
  readonly personality: string;
220
250
  readonly seed: string | null;
221
251
  readonly speed: string;
@@ -224,6 +254,7 @@ interface Timeline {
224
254
  }
225
255
  /** Metadata passed from `human.record()` into the Recording constructor. */
226
256
  interface RecordingTimelineSource {
257
+ readonly name?: string;
227
258
  readonly personality: string;
228
259
  readonly seed: string | null;
229
260
  readonly speed: string;
@@ -288,6 +319,30 @@ declare class Recording {
288
319
  * @returns the resolved output path.
289
320
  */
290
321
  toTimeline(outputPath: string): Promise<string>;
322
+ /**
323
+ * Generates a standalone, runnable HumanJS script from the timeline and
324
+ * writes it to `outputPath`. String selectors round-trip verbatim; typed
325
+ * values are included when `captureInputs` was on (passwords masked).
326
+ *
327
+ * Independent of frame capture — works on timeline-only recordings and is
328
+ * unaffected by `dispose()`.
329
+ *
330
+ * @returns the resolved output path.
331
+ */
332
+ toHumanJS(outputPath: string): Promise<string>;
333
+ /**
334
+ * Generates a `@playwright/test` spec from the timeline — a humanized test
335
+ * (uses `createHuman` + `human.*`), not raw Playwright — and writes it to
336
+ * `outputPath`. Runs instant in CI / recorded speed locally, drops timing
337
+ * `sleep()`s (pass `{ keepSleeps: true }` to keep them), and derives the
338
+ * assertions it safely can.
339
+ *
340
+ * Independent of frame capture — works on timeline-only recordings and is
341
+ * unaffected by `dispose()`.
342
+ *
343
+ * @returns the resolved output path.
344
+ */
345
+ toPlaywright(outputPath: string, options?: PlaywrightTestOptions): Promise<string>;
291
346
  /**
292
347
  * Releases the captured-frames temp directory. After this call, `toVideo()`
293
348
  * and `toGif()` throw — but `toTimeline()` and the in-memory `timeline`
@@ -777,6 +832,11 @@ interface Human {
777
832
  }
778
833
  /** Options for {@link Human.record}. */
779
834
  interface HumanRecordOptions {
835
+ /**
836
+ * Optional label for the recording. Stored on the timeline and used as the
837
+ * title of a generated `toPlaywright()` test.
838
+ */
839
+ readonly name?: string;
780
840
  /**
781
841
  * Whether to capture frames for video output. Defaults to `true`.
782
842
  * Set to `false` for timeline-only recordings (no capture loop, no
@@ -789,6 +849,21 @@ interface HumanRecordOptions {
789
849
  * Defaults to `'high'`. Ignored when `video: false`.
790
850
  */
791
851
  readonly quality?: RecordingQuality;
852
+ /**
853
+ * Capture the actual typed/pasted text into the timeline (and therefore
854
+ * into code generated by `toHumanJS()` / `toPlaywright()`). Defaults to
855
+ * `true`.
856
+ *
857
+ * Values typed/pasted into `input[type="password"]` are always masked
858
+ * (omitted) regardless of this flag — the video already renders them as
859
+ * dots, and freezing a cleartext secret into an artifact would leak more
860
+ * than the recording itself. Set `false` to record no input values at all;
861
+ * exporters then emit empty-string placeholders.
862
+ *
863
+ * Captured values land in the timeline JSON and any exported test, so treat
864
+ * those artifacts with the same care as the values themselves.
865
+ */
866
+ readonly captureInputs?: boolean;
792
867
  }
793
868
  /**
794
869
  * Creates a humanized session bound to a Playwright `Page`.
@@ -811,4 +886,4 @@ interface HumanRecordOptions {
811
886
  */
812
887
  declare function createHuman(page: Page, options?: CreateHumanOptions): Promise<Human>;
813
888
 
814
- export { type CreateHumanOptions, type FfmpegPreset, type FfmpegTune, type Human, type HumanRecordOptions, type InstallMouseHelperOptions, type KeyModifier, type KeyName, type KeyOrChord, type MouseTarget, type PressResult, type ReadOptions, type ReadResult, type ReadTarget, Recording, type RecordingQuality, type ScrollOptions, type ScrollResult, type ScrollTarget, type Speed, type Timeline, type TimelineEvent, type ToGifOptions, type ToVideoOptions, createHuman, installMouseHelper };
889
+ export { type CreateHumanOptions, type FfmpegPreset, type FfmpegTune, type Human, type HumanRecordOptions, type InstallMouseHelperOptions, type KeyModifier, type KeyName, type KeyOrChord, type MouseTarget, type PlaywrightTestOptions, type PressResult, type ReadOptions, type ReadResult, type ReadTarget, Recording, type RecordingQuality, type ScrollOptions, type ScrollResult, type ScrollTarget, type Speed, type Timeline, type TimelineEvent, type ToGifOptions, type ToVideoOptions, createHuman, installMouseHelper };
package/dist/index.d.ts CHANGED
@@ -163,6 +163,28 @@ interface CaptureResult {
163
163
  cleanup(): Promise<void>;
164
164
  }
165
165
 
166
+ /** Options for {@link generatePlaywrightTest} / `Recording.toPlaywright`. */
167
+ interface PlaywrightTestOptions {
168
+ /**
169
+ * Keep recorded `sleep()` pauses. Default `false` — a test shouldn't carry
170
+ * human-timing waits (slow + flaky in CI; Playwright auto-waits instead).
171
+ */
172
+ readonly keepSleeps?: boolean;
173
+ /** Test title. Defaults to the recording's name, else `'recorded session'`. */
174
+ readonly title?: string;
175
+ /**
176
+ * Wrap actions in `test.step(...)` groups — a new step per navigation — so
177
+ * they show as collapsible sections in the HTML report / trace. Default false.
178
+ */
179
+ readonly steps?: boolean;
180
+ /**
181
+ * When all `goto`s share one origin, emit them as relative paths and add a
182
+ * note to set `use.baseURL` in the Playwright config (portable across
183
+ * environments). Default false — absolute URLs that run without any config.
184
+ */
185
+ readonly baseUrl?: boolean;
186
+ }
187
+
166
188
  /**
167
189
  * Encoding quality preset. Picks the per-frame capture quality + the
168
190
  * ffmpeg encode settings used to assemble them into a video.
@@ -212,10 +234,18 @@ interface TimelineEvent {
212
234
  readonly durationMs: number;
213
235
  /** Error message, present only if the action threw. */
214
236
  readonly error?: string;
237
+ /**
238
+ * For `type` / `paste`: the actual text written, captured when
239
+ * `captureInputs` is on (the default). Omitted when capture is off or the
240
+ * target is a password field (always masked). Flows into exported code.
241
+ */
242
+ readonly inputValue?: string;
215
243
  }
216
244
  /** Structured action timeline of a recording. */
217
245
  interface Timeline {
218
246
  readonly version: 1;
247
+ /** Optional label for the recording (used as the generated test's title). */
248
+ readonly name?: string;
219
249
  readonly personality: string;
220
250
  readonly seed: string | null;
221
251
  readonly speed: string;
@@ -224,6 +254,7 @@ interface Timeline {
224
254
  }
225
255
  /** Metadata passed from `human.record()` into the Recording constructor. */
226
256
  interface RecordingTimelineSource {
257
+ readonly name?: string;
227
258
  readonly personality: string;
228
259
  readonly seed: string | null;
229
260
  readonly speed: string;
@@ -288,6 +319,30 @@ declare class Recording {
288
319
  * @returns the resolved output path.
289
320
  */
290
321
  toTimeline(outputPath: string): Promise<string>;
322
+ /**
323
+ * Generates a standalone, runnable HumanJS script from the timeline and
324
+ * writes it to `outputPath`. String selectors round-trip verbatim; typed
325
+ * values are included when `captureInputs` was on (passwords masked).
326
+ *
327
+ * Independent of frame capture — works on timeline-only recordings and is
328
+ * unaffected by `dispose()`.
329
+ *
330
+ * @returns the resolved output path.
331
+ */
332
+ toHumanJS(outputPath: string): Promise<string>;
333
+ /**
334
+ * Generates a `@playwright/test` spec from the timeline — a humanized test
335
+ * (uses `createHuman` + `human.*`), not raw Playwright — and writes it to
336
+ * `outputPath`. Runs instant in CI / recorded speed locally, drops timing
337
+ * `sleep()`s (pass `{ keepSleeps: true }` to keep them), and derives the
338
+ * assertions it safely can.
339
+ *
340
+ * Independent of frame capture — works on timeline-only recordings and is
341
+ * unaffected by `dispose()`.
342
+ *
343
+ * @returns the resolved output path.
344
+ */
345
+ toPlaywright(outputPath: string, options?: PlaywrightTestOptions): Promise<string>;
291
346
  /**
292
347
  * Releases the captured-frames temp directory. After this call, `toVideo()`
293
348
  * and `toGif()` throw — but `toTimeline()` and the in-memory `timeline`
@@ -777,6 +832,11 @@ interface Human {
777
832
  }
778
833
  /** Options for {@link Human.record}. */
779
834
  interface HumanRecordOptions {
835
+ /**
836
+ * Optional label for the recording. Stored on the timeline and used as the
837
+ * title of a generated `toPlaywright()` test.
838
+ */
839
+ readonly name?: string;
780
840
  /**
781
841
  * Whether to capture frames for video output. Defaults to `true`.
782
842
  * Set to `false` for timeline-only recordings (no capture loop, no
@@ -789,6 +849,21 @@ interface HumanRecordOptions {
789
849
  * Defaults to `'high'`. Ignored when `video: false`.
790
850
  */
791
851
  readonly quality?: RecordingQuality;
852
+ /**
853
+ * Capture the actual typed/pasted text into the timeline (and therefore
854
+ * into code generated by `toHumanJS()` / `toPlaywright()`). Defaults to
855
+ * `true`.
856
+ *
857
+ * Values typed/pasted into `input[type="password"]` are always masked
858
+ * (omitted) regardless of this flag — the video already renders them as
859
+ * dots, and freezing a cleartext secret into an artifact would leak more
860
+ * than the recording itself. Set `false` to record no input values at all;
861
+ * exporters then emit empty-string placeholders.
862
+ *
863
+ * Captured values land in the timeline JSON and any exported test, so treat
864
+ * those artifacts with the same care as the values themselves.
865
+ */
866
+ readonly captureInputs?: boolean;
792
867
  }
793
868
  /**
794
869
  * Creates a humanized session bound to a Playwright `Page`.
@@ -811,4 +886,4 @@ interface HumanRecordOptions {
811
886
  */
812
887
  declare function createHuman(page: Page, options?: CreateHumanOptions): Promise<Human>;
813
888
 
814
- export { type CreateHumanOptions, type FfmpegPreset, type FfmpegTune, type Human, type HumanRecordOptions, type InstallMouseHelperOptions, type KeyModifier, type KeyName, type KeyOrChord, type MouseTarget, type PressResult, type ReadOptions, type ReadResult, type ReadTarget, Recording, type RecordingQuality, type ScrollOptions, type ScrollResult, type ScrollTarget, type Speed, type Timeline, type TimelineEvent, type ToGifOptions, type ToVideoOptions, createHuman, installMouseHelper };
889
+ export { type CreateHumanOptions, type FfmpegPreset, type FfmpegTune, type Human, type HumanRecordOptions, type InstallMouseHelperOptions, type KeyModifier, type KeyName, type KeyOrChord, type MouseTarget, type PlaywrightTestOptions, type PressResult, type ReadOptions, type ReadResult, type ReadTarget, Recording, type RecordingQuality, type ScrollOptions, type ScrollResult, type ScrollTarget, type Speed, type Timeline, type TimelineEvent, type ToGifOptions, type ToVideoOptions, createHuman, installMouseHelper };
package/dist/index.js CHANGED
@@ -712,6 +712,205 @@ async function detectKindFromTag(locator) {
712
712
  if (tag === "pre" || tag === "code") return "code";
713
713
  return void 0;
714
714
  }
715
+
716
+ // src/recording/codegen.ts
717
+ var POINT_RE = /^point\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)$/;
718
+ var POINT_COMMENT = " // raw coordinate \u2014 replace with a locator for a stable selector";
719
+ var UNCAPTURED_COMMENT = " // input not captured (masked or captureInputs disabled) \u2014 fill in (e.g. process.env.X)";
720
+ function q(value) {
721
+ return `'${String(value ?? "").replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r")}'`;
722
+ }
723
+ function targetArg(desc) {
724
+ const s = String(desc ?? "");
725
+ const m = s.match(POINT_RE);
726
+ if (m) return { code: `{ x: ${m[1]}, y: ${m[2]} }`, isPoint: true };
727
+ return { code: q(s), isPoint: false };
728
+ }
729
+ function createHumanOptions(timeline, ciSpeed = false) {
730
+ const parts = [` personality: ${q(timeline.personality)},`];
731
+ if (timeline.seed !== null) parts.push(` seed: ${q(timeline.seed)},`);
732
+ parts.push(
733
+ ciSpeed ? ` speed: process.env.CI ? 'instant' : ${q(timeline.speed)},` : ` speed: ${q(timeline.speed)},`
734
+ );
735
+ return `{
736
+ ${parts.join("\n")}
737
+ }`;
738
+ }
739
+ function emitScroll(target) {
740
+ const s = String(target ?? "natural");
741
+ if (s === "natural") return " await human.scroll('natural');";
742
+ const by = s.match(/^by:(-?\d+(?:\.\d+)?)$/);
743
+ if (by) return ` await human.scroll({ by: ${by[1]} });`;
744
+ const to = s.match(/^to:(-?\d+(?:\.\d+)?)$/);
745
+ if (to) return ` await human.scroll({ to: ${to[1]} });`;
746
+ return ` await human.scroll(${q(s)});`;
747
+ }
748
+ function emitAction(e, opts = {}) {
749
+ const p = e.params;
750
+ switch (e.type) {
751
+ case "goto": {
752
+ const url = String(p.url ?? "");
753
+ if (opts.baseOrigin && url.startsWith(opts.baseOrigin)) {
754
+ return ` await human.goto(${q(url.slice(opts.baseOrigin.length) || "/")});`;
755
+ }
756
+ return ` await human.goto(${q(url)});`;
757
+ }
758
+ case "click":
759
+ case "rightClick":
760
+ case "hover":
761
+ case "move": {
762
+ const { code, isPoint: isPoint2 } = targetArg(p.target);
763
+ return ` await human.${e.type}(${code});${isPoint2 ? POINT_COMMENT : ""}`;
764
+ }
765
+ case "drag": {
766
+ const from = targetArg(p.from);
767
+ const to = targetArg(p.to);
768
+ const comment = from.isPoint || to.isPoint ? POINT_COMMENT : "";
769
+ return ` await human.drag(${from.code}, ${to.code});${comment}`;
770
+ }
771
+ case "type":
772
+ case "paste": {
773
+ const { code, isPoint: isPoint2 } = targetArg(p.target);
774
+ if (e.inputValue === void 0) {
775
+ return ` await human.${e.type}(${code}, '');${UNCAPTURED_COMMENT}`;
776
+ }
777
+ const call = ` await human.${e.type}(${code}, ${q(e.inputValue)});`;
778
+ if (opts.asserts && !isPoint2) {
779
+ return `${call}
780
+ await expect(page.locator(${code})).toHaveValue(${q(e.inputValue)});`;
781
+ }
782
+ return call;
783
+ }
784
+ case "press":
785
+ return ` await human.press(${q(p.key)});`;
786
+ case "scroll":
787
+ return emitScroll(p.target);
788
+ case "read": {
789
+ const desc = String(p.target ?? "");
790
+ if (/^\d+ words$/.test(desc) || /^text:\d+ chars$/.test(desc)) {
791
+ return ` // human.read(...) \u2014 ${desc}; original target not captured`;
792
+ }
793
+ const call = ` await human.read(${q(desc)});`;
794
+ if (opts.asserts) return `${call}
795
+ await expect(page.locator(${q(desc)})).toBeVisible();`;
796
+ return call;
797
+ }
798
+ case "sleep":
799
+ return ` await sleep(${Number(p.ms) || 0});`;
800
+ case "reload":
801
+ return " await human.reload();";
802
+ case "goBack":
803
+ return " await human.goBack();";
804
+ case "goForward":
805
+ return " await human.goForward();";
806
+ default:
807
+ return ` // unsupported action: ${e.type}`;
808
+ }
809
+ }
810
+ function needsSleepImport(timeline) {
811
+ return timeline.events.some((e) => e.type === "sleep");
812
+ }
813
+ function generateHumanJS(timeline) {
814
+ const imports = needsSleepImport(timeline) ? "import { chromium, createHuman, sleep } from '@humanjs/playwright';" : "import { chromium, createHuman } from '@humanjs/playwright';";
815
+ const body = timeline.events.map((e) => emitAction(e)).join("\n");
816
+ return `${imports}
817
+
818
+ async function main() {
819
+ const browser = await chromium.launch({ headless: false });
820
+ const page = await browser.newPage();
821
+ const human = await createHuman(page, ${createHumanOptions(timeline)});
822
+
823
+ ${body}
824
+
825
+ await browser.close();
826
+ }
827
+
828
+ main();
829
+ `;
830
+ }
831
+ var NAV_TYPES = /* @__PURE__ */ new Set(["goto", "reload", "goBack", "goForward"]);
832
+ function sharedGotoOrigin(events) {
833
+ let origin;
834
+ for (const e of events) {
835
+ if (e.type !== "goto") continue;
836
+ try {
837
+ const o = new URL(String(e.params.url ?? "")).origin;
838
+ if (origin === void 0) origin = o;
839
+ else if (origin !== o) return void 0;
840
+ } catch {
841
+ return void 0;
842
+ }
843
+ }
844
+ return origin;
845
+ }
846
+ function indentLines(block, pad) {
847
+ return block.split("\n").map((line) => line.length > 0 ? pad + line : line).join("\n");
848
+ }
849
+ function stepLabel(event, index, baseOrigin) {
850
+ switch (event.type) {
851
+ case "goto": {
852
+ const url = String(event.params.url ?? "");
853
+ const path = baseOrigin && url.startsWith(baseOrigin) ? url.slice(baseOrigin.length) || "/" : url;
854
+ return `go to ${path}`;
855
+ }
856
+ case "reload":
857
+ return "reload";
858
+ case "goBack":
859
+ return "go back";
860
+ case "goForward":
861
+ return "go forward";
862
+ default:
863
+ return `step ${index + 1}`;
864
+ }
865
+ }
866
+ function emitSteps(events, opts) {
867
+ const groups = [];
868
+ for (const e of events) {
869
+ const last = groups[groups.length - 1];
870
+ if (last === void 0 || NAV_TYPES.has(e.type)) groups.push([e]);
871
+ else last.push(e);
872
+ }
873
+ return groups.map((group, i) => {
874
+ const [first] = group;
875
+ const label = first ? stepLabel(first, i, opts.baseOrigin) : `step ${i + 1}`;
876
+ const inner = group.map((e) => indentLines(emitAction(e, opts), " ")).join("\n");
877
+ return ` await test.step(${q(label)}, async () => {
878
+ ${inner}
879
+ });`;
880
+ }).join("\n\n");
881
+ }
882
+ function generatePlaywrightTest(timeline, options = {}) {
883
+ const events = options.keepSleeps ? timeline.events : timeline.events.filter((e) => e.type !== "sleep");
884
+ const baseOrigin = options.baseUrl ? sharedGotoOrigin(events) : void 0;
885
+ const emitOpts = { asserts: true, baseOrigin };
886
+ const body = options.steps ? emitSteps(events, emitOpts) : events.map((e) => emitAction(e, emitOpts)).join("\n");
887
+ const needsSleep = events.some((e) => e.type === "sleep");
888
+ const hasAsserts = body.includes("await expect(");
889
+ const testImport = hasAsserts ? "import { expect, test } from '@playwright/test';" : "import { test } from '@playwright/test';";
890
+ const humanImport = needsSleep ? "import { createHuman, sleep } from '@humanjs/playwright';" : "import { createHuman } from '@humanjs/playwright';";
891
+ const title = options.title ?? timeline.name ?? "recorded session";
892
+ const baseUrlNote = baseOrigin ? ` // Set use.baseURL = ${q(baseOrigin)} in playwright.config.ts for these relative paths.
893
+
894
+ ` : "";
895
+ const todo = [
896
+ hasAsserts ? " // TODO: add assertions for the outcome of this flow, e.g.:" : " // TODO: assert the outcome \u2014 import { expect } from '@playwright/test', e.g.:",
897
+ " // await expect(page).toHaveURL(/dashboard/);",
898
+ " // await expect(page.getByText('Welcome back')).toBeVisible();"
899
+ ].join("\n");
900
+ return `${testImport}
901
+ ${humanImport}
902
+
903
+ test(${q(title)}, async ({ page }) => {
904
+ const human = await createHuman(page, ${createHumanOptions(timeline, true)});
905
+
906
+ ${baseUrlNote}${body}
907
+
908
+ ${todo}
909
+ });
910
+ `;
911
+ }
912
+
913
+ // src/recording/index.ts
715
914
  var pendingFrameCleanups = /* @__PURE__ */ new Set();
716
915
  var exitHandlerInstalled = false;
717
916
  function ensureExitHandler() {
@@ -807,6 +1006,7 @@ var Recording = class {
807
1006
  get timeline() {
808
1007
  return {
809
1008
  version: 1,
1009
+ ...this.#timelineSource.name !== void 0 ? { name: this.#timelineSource.name } : {},
810
1010
  personality: this.#timelineSource.personality,
811
1011
  seed: this.#timelineSource.seed,
812
1012
  speed: this.#timelineSource.speed,
@@ -957,6 +1157,38 @@ var Recording = class {
957
1157
  `, "utf8");
958
1158
  return outputPath;
959
1159
  }
1160
+ /**
1161
+ * Generates a standalone, runnable HumanJS script from the timeline and
1162
+ * writes it to `outputPath`. String selectors round-trip verbatim; typed
1163
+ * values are included when `captureInputs` was on (passwords masked).
1164
+ *
1165
+ * Independent of frame capture — works on timeline-only recordings and is
1166
+ * unaffected by `dispose()`.
1167
+ *
1168
+ * @returns the resolved output path.
1169
+ */
1170
+ async toHumanJS(outputPath) {
1171
+ await mkdir(dirname(outputPath), { recursive: true });
1172
+ await writeFile(outputPath, generateHumanJS(this.timeline), "utf8");
1173
+ return outputPath;
1174
+ }
1175
+ /**
1176
+ * Generates a `@playwright/test` spec from the timeline — a humanized test
1177
+ * (uses `createHuman` + `human.*`), not raw Playwright — and writes it to
1178
+ * `outputPath`. Runs instant in CI / recorded speed locally, drops timing
1179
+ * `sleep()`s (pass `{ keepSleeps: true }` to keep them), and derives the
1180
+ * assertions it safely can.
1181
+ *
1182
+ * Independent of frame capture — works on timeline-only recordings and is
1183
+ * unaffected by `dispose()`.
1184
+ *
1185
+ * @returns the resolved output path.
1186
+ */
1187
+ async toPlaywright(outputPath, options) {
1188
+ await mkdir(dirname(outputPath), { recursive: true });
1189
+ await writeFile(outputPath, generatePlaywrightTest(this.timeline, options), "utf8");
1190
+ return outputPath;
1191
+ }
960
1192
  /**
961
1193
  * Releases the captured-frames temp directory. After this call, `toVideo()`
962
1194
  * and `toGif()` throw — but `toTimeline()` and the in-memory `timeline`
@@ -1228,7 +1460,8 @@ async function createHuman(page, options = {}) {
1228
1460
  let hasRecorded = false;
1229
1461
  let activeRecordingEvents = null;
1230
1462
  let activeRecordingStartMs = 0;
1231
- async function performAction(action, actionFn) {
1463
+ let activeRecordingCaptureInputs = false;
1464
+ async function performAction(action, actionFn, recordMeta) {
1232
1465
  for (const plugin of plugins) {
1233
1466
  await plugin.beforeAction?.(action);
1234
1467
  }
@@ -1242,7 +1475,8 @@ async function createHuman(page, options = {}) {
1242
1475
  type: action.type,
1243
1476
  params: action.params ?? {},
1244
1477
  tMs: startedAt - activeRecordingStartMs,
1245
- durationMs
1478
+ durationMs,
1479
+ ...recordMeta?.inputValue !== void 0 ? { inputValue: recordMeta.inputValue } : {}
1246
1480
  });
1247
1481
  }
1248
1482
  for (const plugin of plugins) {
@@ -1256,7 +1490,8 @@ async function createHuman(page, options = {}) {
1256
1490
  params: action.params ?? {},
1257
1491
  tMs: startedAt - activeRecordingStartMs,
1258
1492
  durationMs: Date.now() - startedAt,
1259
- error: error instanceof Error ? error.message : String(error)
1493
+ error: error instanceof Error ? error.message : String(error),
1494
+ ...recordMeta?.inputValue !== void 0 ? { inputValue: recordMeta.inputValue } : {}
1260
1495
  });
1261
1496
  }
1262
1497
  for (const plugin of plugins) {
@@ -1283,6 +1518,21 @@ async function createHuman(page, options = {}) {
1283
1518
  }
1284
1519
  return target.toString?.() ?? "locator";
1285
1520
  };
1521
+ const isPointTarget = (target) => typeof target !== "string" && "x" in target && "y" in target && typeof target.x === "number";
1522
+ const captureInputValue = async (target, value) => {
1523
+ if (activeRecordingEvents === null || !activeRecordingCaptureInputs) return void 0;
1524
+ const locator = typeof target === "string" ? page.locator(target) : isPointTarget(target) ? null : target;
1525
+ if (locator !== null) {
1526
+ try {
1527
+ const fieldType = await locator.first().getAttribute("type", { timeout: 1e3 });
1528
+ if (fieldType?.toLowerCase() === "password") {
1529
+ return void 0;
1530
+ }
1531
+ } catch {
1532
+ }
1533
+ }
1534
+ return value;
1535
+ };
1286
1536
  return {
1287
1537
  personality,
1288
1538
  speed,
@@ -1324,6 +1574,7 @@ async function createHuman(page, options = {}) {
1324
1574
  },
1325
1575
  async type(target, value) {
1326
1576
  const description = describeMouseTarget(target);
1577
+ const inputValue = await captureInputValue(target, value);
1327
1578
  await performAction(
1328
1579
  { type: "type", params: { target: description, length: value.length } },
1329
1580
  async () => {
@@ -1331,11 +1582,13 @@ async function createHuman(page, options = {}) {
1331
1582
  await executeClick(target, mouseCtx());
1332
1583
  }
1333
1584
  await executeType(target, value, { page, personality, rng, speed });
1334
- }
1585
+ },
1586
+ inputValue !== void 0 ? { inputValue } : void 0
1335
1587
  );
1336
1588
  },
1337
1589
  async paste(target, value) {
1338
1590
  const description = describeMouseTarget(target);
1591
+ const inputValue = await captureInputValue(target, value);
1339
1592
  await performAction(
1340
1593
  { type: "paste", params: { target: description, length: value.length } },
1341
1594
  async () => {
@@ -1343,7 +1596,8 @@ async function createHuman(page, options = {}) {
1343
1596
  await executeClick(target, mouseCtx());
1344
1597
  }
1345
1598
  await executePaste(target, value, { page, personality, rng, speed });
1346
- }
1599
+ },
1600
+ inputValue !== void 0 ? { inputValue } : void 0
1347
1601
  );
1348
1602
  },
1349
1603
  async press(key) {
@@ -1412,6 +1666,7 @@ async function createHuman(page, options = {}) {
1412
1666
  const windowStartMs = Date.now();
1413
1667
  activeRecordingEvents = events;
1414
1668
  activeRecordingStartMs = windowStartMs;
1669
+ activeRecordingCaptureInputs = recordOptions.captureInputs !== false;
1415
1670
  let windowEndMs = windowStartMs;
1416
1671
  try {
1417
1672
  await performAction({ type: "record", params: {} }, async () => {
@@ -1426,9 +1681,11 @@ async function createHuman(page, options = {}) {
1426
1681
  throw error;
1427
1682
  } finally {
1428
1683
  activeRecordingEvents = null;
1684
+ activeRecordingCaptureInputs = false;
1429
1685
  }
1430
1686
  const captureResult = captureSession ? await captureSession.stop() : null;
1431
1687
  return new Recording(captureResult, windowStartMs, windowEndMs, {
1688
+ name: recordOptions.name,
1432
1689
  personality: personality.name,
1433
1690
  seed: options.seed === void 0 ? null : String(options.seed),
1434
1691
  speed,