@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/README.md +26 -1
- package/dist/index.cjs +262 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +76 -1
- package/dist/index.d.ts +76 -1
- package/dist/index.js +262 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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,
|