@humanjs/playwright 0.5.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 +68 -1
- package/dist/index.cjs +322 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +188 -9
- package/dist/index.d.ts +188 -9
- package/dist/index.js +322 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @humanjs/playwright
|
|
2
2
|
|
|
3
|
+
<p>
|
|
4
|
+
<a href="https://www.npmjs.com/package/@humanjs/playwright"><img alt="npm" src="https://img.shields.io/npm/v/@humanjs/playwright"></a>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@humanjs/playwright"><img alt="downloads" src="https://img.shields.io/npm/dt/@humanjs/playwright"></a>
|
|
6
|
+
<a href="https://github.com/totigm/humanjs"><img alt="GitHub" src="https://img.shields.io/badge/GitHub-totigm%2Fhumanjs-181717?logo=github"></a>
|
|
7
|
+
<a href="https://github.com/totigm/humanjs/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/totigm/humanjs/actions/workflows/ci.yml/badge.svg"></a>
|
|
8
|
+
<a href="https://github.com/totigm/humanjs/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/@humanjs/playwright"></a>
|
|
9
|
+
<a href="https://humanjs.dev"><img alt="docs" src="https://img.shields.io/badge/docs-humanjs.dev-emerald"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
3
12
|
Humanize Playwright sessions for AI agents, QA tests, and demos. Drop-in adapter for an existing Playwright `Page`.
|
|
4
13
|
|
|
5
14
|
## Install
|
|
@@ -332,7 +341,32 @@ await rec.toTimeline('session.json'); // → JSON on disk
|
|
|
332
341
|
const timeline = rec.timeline; // → in-memory object
|
|
333
342
|
```
|
|
334
343
|
|
|
335
|
-
The shape (`Timeline` with `personality`, `seed`, `speed`, `durationMs`, and an `events` array of `{ type, params, tMs, durationMs }`) is intended for observability pipelines, replay infrastructure, analytics, and debugger UIs. `toTimeline()` doesn't touch the browser context — call it before or after `toVideo()`, multiple times, in any order.
|
|
344
|
+
The shape (`Timeline` with `personality`, `seed`, `speed`, `durationMs`, and an `events` array of `{ type, params, tMs, durationMs }`, plus `inputValue` on captured `type`/`paste` events) is intended for observability pipelines, replay infrastructure, analytics, and debugger UIs. `toTimeline()` doesn't touch the browser context — call it before or after `toVideo()`, multiple times, in any order.
|
|
345
|
+
|
|
346
|
+
**Code export** — turn the same recording into runnable code:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
await rec.toHumanJS('session.ts'); // standalone HumanJS script
|
|
350
|
+
await rec.toPlaywright('session.spec.ts'); // @playwright/test spec (humanized)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`toHumanJS()` emits a standalone script (`createHuman` + `human.*`); `toPlaywright()` emits a `@playwright/test` spec that drives the page through HumanJS, so the generated test runs humanized too. String selectors round-trip verbatim. Both work on timeline-only recordings and are unaffected by `dispose()`.
|
|
354
|
+
|
|
355
|
+
`toPlaywright()` also derives the assertions it safely can from the recording — a `read` implies its target was visible (`toBeVisible`), a captured input implies its value (`toHaveValue`) — and leaves a `TODO` for outcome assertions (URL changed, text appeared) that can't be inferred from actions alone. It never fabricates assertions that might fail on a correct run.
|
|
356
|
+
|
|
357
|
+
Generated tests are built to *be tests*: they run `speed: process.env.CI ? 'instant' : '<recorded>'` (instant in CI, recorded feel locally) and drop recorded `sleep()` pauses (timing fidelity belongs in a demo, not a test — pass `toPlaywright(path, { keepSleeps: true })` to keep them). The test title comes from the recording's name (`human.record({ name })`) and is overridable with `{ title }`.
|
|
358
|
+
|
|
359
|
+
Two more options: `{ steps: true }` groups the actions into `test.step(...)` blocks (a new step per navigation) for collapsible sections in the HTML report and trace; `{ baseUrl: true }` rewrites same-origin `goto`s to relative paths and adds a note to set `use.baseURL` in your `playwright.config.ts` — so the same test runs against local / staging / prod.
|
|
360
|
+
|
|
361
|
+
By default the actual typed/pasted text is captured into the timeline (and the exported code). Values typed into `input[type="password"]` are always masked; set `captureInputs: false` to record none — exports then emit empty-string placeholders:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
await human.record({ captureInputs: false }, fn);
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
> Captured input values land in the timeline JSON and any exported code — treat those artifacts with the same care as the values themselves.
|
|
368
|
+
|
|
369
|
+
Two limits, by design: a target passed as a `Locator` or a raw `point(x, y)` doesn't round-trip to a clean selector (points are emitted verbatim with a flag comment — locator/point → selector synthesis is a planned follow-up), and reads driven by word-count or raw text emit a note instead of code.
|
|
336
370
|
|
|
337
371
|
**Quality presets** trade off file size, encoding time, and visual fidelity. Defaults to `'high'`:
|
|
338
372
|
|
|
@@ -364,6 +398,39 @@ await rec.toTimeline('session.json'); // works
|
|
|
364
398
|
|
|
365
399
|
Every recording is a regular plugin action — `beforeAction` and `afterAction` observe `{ type: 'record' }` exactly like `'click'` or `'scroll'`.
|
|
366
400
|
|
|
401
|
+
## Using your own browser or a persistent profile
|
|
402
|
+
|
|
403
|
+
`createHuman(page)` wraps **any** Playwright `Page` — so reusing a saved login, your installed Chrome, or an already-running browser is just a matter of how you create that page. HumanJS adds nothing special here; these are standard Playwright entry points, collected so you don't have to hunt for them.
|
|
404
|
+
|
|
405
|
+
**Persistent profile** — keep cookies, local storage, and logins across runs. The first run signs in; later runs are already authenticated:
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
import { chromium, createHuman } from '@humanjs/playwright';
|
|
409
|
+
|
|
410
|
+
const context = await chromium.launchPersistentContext('./.humanjs-profile', {
|
|
411
|
+
headless: false,
|
|
412
|
+
channel: 'chrome', // optional: use installed Google Chrome instead of bundled Chromium
|
|
413
|
+
});
|
|
414
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
415
|
+
|
|
416
|
+
const human = await createHuman(page, { personality: 'careful' });
|
|
417
|
+
// …drive the page; state persists in ./.humanjs-profile for next time
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**Attach to an already-running browser** — drive a Chrome you started yourself, with all its existing tabs, extensions, and sessions. Launch Chrome with a debugging port first (`chrome --remote-debugging-port=9222`), then:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
import { chromium, createHuman } from '@humanjs/playwright';
|
|
424
|
+
|
|
425
|
+
const browser = await chromium.connectOverCDP('http://localhost:9222');
|
|
426
|
+
const context = browser.contexts()[0];
|
|
427
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
428
|
+
|
|
429
|
+
const human = await createHuman(page, { personality: 'careful' });
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
> **Heads up:** a persistent profile or a connected real browser carries whatever you're signed into. Driving it means the automation can act with those sessions' privileges — keep that in mind for anything sensitive, and be wary of pages that try to manipulate an agent into actions while logged in.
|
|
433
|
+
|
|
367
434
|
## License
|
|
368
435
|
|
|
369
436
|
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -325,15 +325,22 @@ function clamp(value, min, max) {
|
|
|
325
325
|
// src/mouse/index.ts
|
|
326
326
|
async function executeClick(target, ctx, options = {}) {
|
|
327
327
|
const button = options.button ?? "left";
|
|
328
|
-
const locator = typeof target === "string" ? ctx.page.locator(target) : target;
|
|
329
328
|
if (ctx.speed === "instant") {
|
|
330
|
-
|
|
329
|
+
if (isPoint(target)) {
|
|
330
|
+
await ctx.page.mouse.click(target.x, target.y, { button });
|
|
331
|
+
ctx.setMousePosition(target);
|
|
332
|
+
return { target };
|
|
333
|
+
}
|
|
334
|
+
const locator = typeof target === "string" ? ctx.page.locator(target) : target;
|
|
335
|
+
const box2 = await locator.boundingBox();
|
|
331
336
|
await locator.click({ button });
|
|
332
|
-
const center =
|
|
337
|
+
const center = box2 ? { x: box2.x + box2.width / 2, y: box2.y + box2.height / 2 } : ctx.getMousePosition();
|
|
333
338
|
ctx.setMousePosition(center);
|
|
334
339
|
return { target: center };
|
|
335
340
|
}
|
|
336
|
-
const targetPoint = await
|
|
341
|
+
const { point: targetPoint, box } = await resolveTargetPointAndBox(target, ctx, "click");
|
|
342
|
+
await maybeMisclickBeat(ctx, box, targetPoint);
|
|
343
|
+
await walkBezierTo(targetPoint, ctx);
|
|
337
344
|
const preClickMs = computeDwellTime(
|
|
338
345
|
ctx.personality.dwell.preClickMs,
|
|
339
346
|
ctx.personality.dwell.preClickJitter,
|
|
@@ -362,7 +369,7 @@ async function executeHover(target, ctx) {
|
|
|
362
369
|
ctx.setMousePosition(center);
|
|
363
370
|
return { target: center };
|
|
364
371
|
}
|
|
365
|
-
const targetPoint = await moveToTarget(target, ctx
|
|
372
|
+
const targetPoint = await moveToTarget(target, ctx);
|
|
366
373
|
const dwellMs = computeDwellTime(
|
|
367
374
|
ctx.personality.dwell.preClickMs,
|
|
368
375
|
ctx.personality.dwell.preClickJitter,
|
|
@@ -445,10 +452,9 @@ async function executeMove(target, ctx) {
|
|
|
445
452
|
ctx.setMousePosition(point);
|
|
446
453
|
return { target: point };
|
|
447
454
|
}
|
|
448
|
-
async function moveToTarget(target, ctx
|
|
449
|
-
const box = await readBoxWithAutoScroll(target, ctx,
|
|
455
|
+
async function moveToTarget(target, ctx) {
|
|
456
|
+
const box = await readBoxWithAutoScroll(target, ctx, "hover");
|
|
450
457
|
const targetPoint = pickClickPoint(box, ctx.rng, ctx.personality.mouse.clickSpread);
|
|
451
|
-
if (action === "click") await maybeMisclickBeat(ctx, box, targetPoint);
|
|
452
458
|
await walkBezierTo(targetPoint, ctx);
|
|
453
459
|
return targetPoint;
|
|
454
460
|
}
|
|
@@ -711,6 +717,205 @@ async function detectKindFromTag(locator) {
|
|
|
711
717
|
if (tag === "pre" || tag === "code") return "code";
|
|
712
718
|
return void 0;
|
|
713
719
|
}
|
|
720
|
+
|
|
721
|
+
// src/recording/codegen.ts
|
|
722
|
+
var POINT_RE = /^point\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)$/;
|
|
723
|
+
var POINT_COMMENT = " // raw coordinate \u2014 replace with a locator for a stable selector";
|
|
724
|
+
var UNCAPTURED_COMMENT = " // input not captured (masked or captureInputs disabled) \u2014 fill in (e.g. process.env.X)";
|
|
725
|
+
function q(value) {
|
|
726
|
+
return `'${String(value ?? "").replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r")}'`;
|
|
727
|
+
}
|
|
728
|
+
function targetArg(desc) {
|
|
729
|
+
const s = String(desc ?? "");
|
|
730
|
+
const m = s.match(POINT_RE);
|
|
731
|
+
if (m) return { code: `{ x: ${m[1]}, y: ${m[2]} }`, isPoint: true };
|
|
732
|
+
return { code: q(s), isPoint: false };
|
|
733
|
+
}
|
|
734
|
+
function createHumanOptions(timeline, ciSpeed = false) {
|
|
735
|
+
const parts = [` personality: ${q(timeline.personality)},`];
|
|
736
|
+
if (timeline.seed !== null) parts.push(` seed: ${q(timeline.seed)},`);
|
|
737
|
+
parts.push(
|
|
738
|
+
ciSpeed ? ` speed: process.env.CI ? 'instant' : ${q(timeline.speed)},` : ` speed: ${q(timeline.speed)},`
|
|
739
|
+
);
|
|
740
|
+
return `{
|
|
741
|
+
${parts.join("\n")}
|
|
742
|
+
}`;
|
|
743
|
+
}
|
|
744
|
+
function emitScroll(target) {
|
|
745
|
+
const s = String(target ?? "natural");
|
|
746
|
+
if (s === "natural") return " await human.scroll('natural');";
|
|
747
|
+
const by = s.match(/^by:(-?\d+(?:\.\d+)?)$/);
|
|
748
|
+
if (by) return ` await human.scroll({ by: ${by[1]} });`;
|
|
749
|
+
const to = s.match(/^to:(-?\d+(?:\.\d+)?)$/);
|
|
750
|
+
if (to) return ` await human.scroll({ to: ${to[1]} });`;
|
|
751
|
+
return ` await human.scroll(${q(s)});`;
|
|
752
|
+
}
|
|
753
|
+
function emitAction(e, opts = {}) {
|
|
754
|
+
const p = e.params;
|
|
755
|
+
switch (e.type) {
|
|
756
|
+
case "goto": {
|
|
757
|
+
const url = String(p.url ?? "");
|
|
758
|
+
if (opts.baseOrigin && url.startsWith(opts.baseOrigin)) {
|
|
759
|
+
return ` await human.goto(${q(url.slice(opts.baseOrigin.length) || "/")});`;
|
|
760
|
+
}
|
|
761
|
+
return ` await human.goto(${q(url)});`;
|
|
762
|
+
}
|
|
763
|
+
case "click":
|
|
764
|
+
case "rightClick":
|
|
765
|
+
case "hover":
|
|
766
|
+
case "move": {
|
|
767
|
+
const { code, isPoint: isPoint2 } = targetArg(p.target);
|
|
768
|
+
return ` await human.${e.type}(${code});${isPoint2 ? POINT_COMMENT : ""}`;
|
|
769
|
+
}
|
|
770
|
+
case "drag": {
|
|
771
|
+
const from = targetArg(p.from);
|
|
772
|
+
const to = targetArg(p.to);
|
|
773
|
+
const comment = from.isPoint || to.isPoint ? POINT_COMMENT : "";
|
|
774
|
+
return ` await human.drag(${from.code}, ${to.code});${comment}`;
|
|
775
|
+
}
|
|
776
|
+
case "type":
|
|
777
|
+
case "paste": {
|
|
778
|
+
const { code, isPoint: isPoint2 } = targetArg(p.target);
|
|
779
|
+
if (e.inputValue === void 0) {
|
|
780
|
+
return ` await human.${e.type}(${code}, '');${UNCAPTURED_COMMENT}`;
|
|
781
|
+
}
|
|
782
|
+
const call = ` await human.${e.type}(${code}, ${q(e.inputValue)});`;
|
|
783
|
+
if (opts.asserts && !isPoint2) {
|
|
784
|
+
return `${call}
|
|
785
|
+
await expect(page.locator(${code})).toHaveValue(${q(e.inputValue)});`;
|
|
786
|
+
}
|
|
787
|
+
return call;
|
|
788
|
+
}
|
|
789
|
+
case "press":
|
|
790
|
+
return ` await human.press(${q(p.key)});`;
|
|
791
|
+
case "scroll":
|
|
792
|
+
return emitScroll(p.target);
|
|
793
|
+
case "read": {
|
|
794
|
+
const desc = String(p.target ?? "");
|
|
795
|
+
if (/^\d+ words$/.test(desc) || /^text:\d+ chars$/.test(desc)) {
|
|
796
|
+
return ` // human.read(...) \u2014 ${desc}; original target not captured`;
|
|
797
|
+
}
|
|
798
|
+
const call = ` await human.read(${q(desc)});`;
|
|
799
|
+
if (opts.asserts) return `${call}
|
|
800
|
+
await expect(page.locator(${q(desc)})).toBeVisible();`;
|
|
801
|
+
return call;
|
|
802
|
+
}
|
|
803
|
+
case "sleep":
|
|
804
|
+
return ` await sleep(${Number(p.ms) || 0});`;
|
|
805
|
+
case "reload":
|
|
806
|
+
return " await human.reload();";
|
|
807
|
+
case "goBack":
|
|
808
|
+
return " await human.goBack();";
|
|
809
|
+
case "goForward":
|
|
810
|
+
return " await human.goForward();";
|
|
811
|
+
default:
|
|
812
|
+
return ` // unsupported action: ${e.type}`;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function needsSleepImport(timeline) {
|
|
816
|
+
return timeline.events.some((e) => e.type === "sleep");
|
|
817
|
+
}
|
|
818
|
+
function generateHumanJS(timeline) {
|
|
819
|
+
const imports = needsSleepImport(timeline) ? "import { chromium, createHuman, sleep } from '@humanjs/playwright';" : "import { chromium, createHuman } from '@humanjs/playwright';";
|
|
820
|
+
const body = timeline.events.map((e) => emitAction(e)).join("\n");
|
|
821
|
+
return `${imports}
|
|
822
|
+
|
|
823
|
+
async function main() {
|
|
824
|
+
const browser = await chromium.launch({ headless: false });
|
|
825
|
+
const page = await browser.newPage();
|
|
826
|
+
const human = await createHuman(page, ${createHumanOptions(timeline)});
|
|
827
|
+
|
|
828
|
+
${body}
|
|
829
|
+
|
|
830
|
+
await browser.close();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
main();
|
|
834
|
+
`;
|
|
835
|
+
}
|
|
836
|
+
var NAV_TYPES = /* @__PURE__ */ new Set(["goto", "reload", "goBack", "goForward"]);
|
|
837
|
+
function sharedGotoOrigin(events) {
|
|
838
|
+
let origin;
|
|
839
|
+
for (const e of events) {
|
|
840
|
+
if (e.type !== "goto") continue;
|
|
841
|
+
try {
|
|
842
|
+
const o = new URL(String(e.params.url ?? "")).origin;
|
|
843
|
+
if (origin === void 0) origin = o;
|
|
844
|
+
else if (origin !== o) return void 0;
|
|
845
|
+
} catch {
|
|
846
|
+
return void 0;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return origin;
|
|
850
|
+
}
|
|
851
|
+
function indentLines(block, pad) {
|
|
852
|
+
return block.split("\n").map((line) => line.length > 0 ? pad + line : line).join("\n");
|
|
853
|
+
}
|
|
854
|
+
function stepLabel(event, index, baseOrigin) {
|
|
855
|
+
switch (event.type) {
|
|
856
|
+
case "goto": {
|
|
857
|
+
const url = String(event.params.url ?? "");
|
|
858
|
+
const path = baseOrigin && url.startsWith(baseOrigin) ? url.slice(baseOrigin.length) || "/" : url;
|
|
859
|
+
return `go to ${path}`;
|
|
860
|
+
}
|
|
861
|
+
case "reload":
|
|
862
|
+
return "reload";
|
|
863
|
+
case "goBack":
|
|
864
|
+
return "go back";
|
|
865
|
+
case "goForward":
|
|
866
|
+
return "go forward";
|
|
867
|
+
default:
|
|
868
|
+
return `step ${index + 1}`;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function emitSteps(events, opts) {
|
|
872
|
+
const groups = [];
|
|
873
|
+
for (const e of events) {
|
|
874
|
+
const last = groups[groups.length - 1];
|
|
875
|
+
if (last === void 0 || NAV_TYPES.has(e.type)) groups.push([e]);
|
|
876
|
+
else last.push(e);
|
|
877
|
+
}
|
|
878
|
+
return groups.map((group, i) => {
|
|
879
|
+
const [first] = group;
|
|
880
|
+
const label = first ? stepLabel(first, i, opts.baseOrigin) : `step ${i + 1}`;
|
|
881
|
+
const inner = group.map((e) => indentLines(emitAction(e, opts), " ")).join("\n");
|
|
882
|
+
return ` await test.step(${q(label)}, async () => {
|
|
883
|
+
${inner}
|
|
884
|
+
});`;
|
|
885
|
+
}).join("\n\n");
|
|
886
|
+
}
|
|
887
|
+
function generatePlaywrightTest(timeline, options = {}) {
|
|
888
|
+
const events = options.keepSleeps ? timeline.events : timeline.events.filter((e) => e.type !== "sleep");
|
|
889
|
+
const baseOrigin = options.baseUrl ? sharedGotoOrigin(events) : void 0;
|
|
890
|
+
const emitOpts = { asserts: true, baseOrigin };
|
|
891
|
+
const body = options.steps ? emitSteps(events, emitOpts) : events.map((e) => emitAction(e, emitOpts)).join("\n");
|
|
892
|
+
const needsSleep = events.some((e) => e.type === "sleep");
|
|
893
|
+
const hasAsserts = body.includes("await expect(");
|
|
894
|
+
const testImport = hasAsserts ? "import { expect, test } from '@playwright/test';" : "import { test } from '@playwright/test';";
|
|
895
|
+
const humanImport = needsSleep ? "import { createHuman, sleep } from '@humanjs/playwright';" : "import { createHuman } from '@humanjs/playwright';";
|
|
896
|
+
const title = options.title ?? timeline.name ?? "recorded session";
|
|
897
|
+
const baseUrlNote = baseOrigin ? ` // Set use.baseURL = ${q(baseOrigin)} in playwright.config.ts for these relative paths.
|
|
898
|
+
|
|
899
|
+
` : "";
|
|
900
|
+
const todo = [
|
|
901
|
+
hasAsserts ? " // TODO: add assertions for the outcome of this flow, e.g.:" : " // TODO: assert the outcome \u2014 import { expect } from '@playwright/test', e.g.:",
|
|
902
|
+
" // await expect(page).toHaveURL(/dashboard/);",
|
|
903
|
+
" // await expect(page.getByText('Welcome back')).toBeVisible();"
|
|
904
|
+
].join("\n");
|
|
905
|
+
return `${testImport}
|
|
906
|
+
${humanImport}
|
|
907
|
+
|
|
908
|
+
test(${q(title)}, async ({ page }) => {
|
|
909
|
+
const human = await createHuman(page, ${createHumanOptions(timeline, true)});
|
|
910
|
+
|
|
911
|
+
${baseUrlNote}${body}
|
|
912
|
+
|
|
913
|
+
${todo}
|
|
914
|
+
});
|
|
915
|
+
`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/recording/index.ts
|
|
714
919
|
var pendingFrameCleanups = /* @__PURE__ */ new Set();
|
|
715
920
|
var exitHandlerInstalled = false;
|
|
716
921
|
function ensureExitHandler() {
|
|
@@ -806,6 +1011,7 @@ var Recording = class {
|
|
|
806
1011
|
get timeline() {
|
|
807
1012
|
return {
|
|
808
1013
|
version: 1,
|
|
1014
|
+
...this.#timelineSource.name !== void 0 ? { name: this.#timelineSource.name } : {},
|
|
809
1015
|
personality: this.#timelineSource.personality,
|
|
810
1016
|
seed: this.#timelineSource.seed,
|
|
811
1017
|
speed: this.#timelineSource.speed,
|
|
@@ -956,6 +1162,38 @@ var Recording = class {
|
|
|
956
1162
|
`, "utf8");
|
|
957
1163
|
return outputPath;
|
|
958
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Generates a standalone, runnable HumanJS script from the timeline and
|
|
1167
|
+
* writes it to `outputPath`. String selectors round-trip verbatim; typed
|
|
1168
|
+
* values are included when `captureInputs` was on (passwords masked).
|
|
1169
|
+
*
|
|
1170
|
+
* Independent of frame capture — works on timeline-only recordings and is
|
|
1171
|
+
* unaffected by `dispose()`.
|
|
1172
|
+
*
|
|
1173
|
+
* @returns the resolved output path.
|
|
1174
|
+
*/
|
|
1175
|
+
async toHumanJS(outputPath) {
|
|
1176
|
+
await promises.mkdir(path.dirname(outputPath), { recursive: true });
|
|
1177
|
+
await promises.writeFile(outputPath, generateHumanJS(this.timeline), "utf8");
|
|
1178
|
+
return outputPath;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Generates a `@playwright/test` spec from the timeline — a humanized test
|
|
1182
|
+
* (uses `createHuman` + `human.*`), not raw Playwright — and writes it to
|
|
1183
|
+
* `outputPath`. Runs instant in CI / recorded speed locally, drops timing
|
|
1184
|
+
* `sleep()`s (pass `{ keepSleeps: true }` to keep them), and derives the
|
|
1185
|
+
* assertions it safely can.
|
|
1186
|
+
*
|
|
1187
|
+
* Independent of frame capture — works on timeline-only recordings and is
|
|
1188
|
+
* unaffected by `dispose()`.
|
|
1189
|
+
*
|
|
1190
|
+
* @returns the resolved output path.
|
|
1191
|
+
*/
|
|
1192
|
+
async toPlaywright(outputPath, options) {
|
|
1193
|
+
await promises.mkdir(path.dirname(outputPath), { recursive: true });
|
|
1194
|
+
await promises.writeFile(outputPath, generatePlaywrightTest(this.timeline, options), "utf8");
|
|
1195
|
+
return outputPath;
|
|
1196
|
+
}
|
|
959
1197
|
/**
|
|
960
1198
|
* Releases the captured-frames temp directory. After this call, `toVideo()`
|
|
961
1199
|
* and `toGif()` throw — but `toTimeline()` and the in-memory `timeline`
|
|
@@ -1227,7 +1465,8 @@ async function createHuman(page, options = {}) {
|
|
|
1227
1465
|
let hasRecorded = false;
|
|
1228
1466
|
let activeRecordingEvents = null;
|
|
1229
1467
|
let activeRecordingStartMs = 0;
|
|
1230
|
-
|
|
1468
|
+
let activeRecordingCaptureInputs = false;
|
|
1469
|
+
async function performAction(action, actionFn, recordMeta) {
|
|
1231
1470
|
for (const plugin of plugins) {
|
|
1232
1471
|
await plugin.beforeAction?.(action);
|
|
1233
1472
|
}
|
|
@@ -1241,7 +1480,8 @@ async function createHuman(page, options = {}) {
|
|
|
1241
1480
|
type: action.type,
|
|
1242
1481
|
params: action.params ?? {},
|
|
1243
1482
|
tMs: startedAt - activeRecordingStartMs,
|
|
1244
|
-
durationMs
|
|
1483
|
+
durationMs,
|
|
1484
|
+
...recordMeta?.inputValue !== void 0 ? { inputValue: recordMeta.inputValue } : {}
|
|
1245
1485
|
});
|
|
1246
1486
|
}
|
|
1247
1487
|
for (const plugin of plugins) {
|
|
@@ -1255,7 +1495,8 @@ async function createHuman(page, options = {}) {
|
|
|
1255
1495
|
params: action.params ?? {},
|
|
1256
1496
|
tMs: startedAt - activeRecordingStartMs,
|
|
1257
1497
|
durationMs: Date.now() - startedAt,
|
|
1258
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1498
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1499
|
+
...recordMeta?.inputValue !== void 0 ? { inputValue: recordMeta.inputValue } : {}
|
|
1259
1500
|
});
|
|
1260
1501
|
}
|
|
1261
1502
|
for (const plugin of plugins) {
|
|
@@ -1282,6 +1523,21 @@ async function createHuman(page, options = {}) {
|
|
|
1282
1523
|
}
|
|
1283
1524
|
return target.toString?.() ?? "locator";
|
|
1284
1525
|
};
|
|
1526
|
+
const isPointTarget = (target) => typeof target !== "string" && "x" in target && "y" in target && typeof target.x === "number";
|
|
1527
|
+
const captureInputValue = async (target, value) => {
|
|
1528
|
+
if (activeRecordingEvents === null || !activeRecordingCaptureInputs) return void 0;
|
|
1529
|
+
const locator = typeof target === "string" ? page.locator(target) : isPointTarget(target) ? null : target;
|
|
1530
|
+
if (locator !== null) {
|
|
1531
|
+
try {
|
|
1532
|
+
const fieldType = await locator.first().getAttribute("type", { timeout: 1e3 });
|
|
1533
|
+
if (fieldType?.toLowerCase() === "password") {
|
|
1534
|
+
return void 0;
|
|
1535
|
+
}
|
|
1536
|
+
} catch {
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return value;
|
|
1540
|
+
};
|
|
1285
1541
|
return {
|
|
1286
1542
|
personality,
|
|
1287
1543
|
speed,
|
|
@@ -1323,6 +1579,7 @@ async function createHuman(page, options = {}) {
|
|
|
1323
1579
|
},
|
|
1324
1580
|
async type(target, value) {
|
|
1325
1581
|
const description = describeMouseTarget(target);
|
|
1582
|
+
const inputValue = await captureInputValue(target, value);
|
|
1326
1583
|
await performAction(
|
|
1327
1584
|
{ type: "type", params: { target: description, length: value.length } },
|
|
1328
1585
|
async () => {
|
|
@@ -1330,11 +1587,13 @@ async function createHuman(page, options = {}) {
|
|
|
1330
1587
|
await executeClick(target, mouseCtx());
|
|
1331
1588
|
}
|
|
1332
1589
|
await executeType(target, value, { page, personality, rng, speed });
|
|
1333
|
-
}
|
|
1590
|
+
},
|
|
1591
|
+
inputValue !== void 0 ? { inputValue } : void 0
|
|
1334
1592
|
);
|
|
1335
1593
|
},
|
|
1336
1594
|
async paste(target, value) {
|
|
1337
1595
|
const description = describeMouseTarget(target);
|
|
1596
|
+
const inputValue = await captureInputValue(target, value);
|
|
1338
1597
|
await performAction(
|
|
1339
1598
|
{ type: "paste", params: { target: description, length: value.length } },
|
|
1340
1599
|
async () => {
|
|
@@ -1342,7 +1601,8 @@ async function createHuman(page, options = {}) {
|
|
|
1342
1601
|
await executeClick(target, mouseCtx());
|
|
1343
1602
|
}
|
|
1344
1603
|
await executePaste(target, value, { page, personality, rng, speed });
|
|
1345
|
-
}
|
|
1604
|
+
},
|
|
1605
|
+
inputValue !== void 0 ? { inputValue } : void 0
|
|
1346
1606
|
);
|
|
1347
1607
|
},
|
|
1348
1608
|
async press(key) {
|
|
@@ -1411,6 +1671,7 @@ async function createHuman(page, options = {}) {
|
|
|
1411
1671
|
const windowStartMs = Date.now();
|
|
1412
1672
|
activeRecordingEvents = events;
|
|
1413
1673
|
activeRecordingStartMs = windowStartMs;
|
|
1674
|
+
activeRecordingCaptureInputs = recordOptions.captureInputs !== false;
|
|
1414
1675
|
let windowEndMs = windowStartMs;
|
|
1415
1676
|
try {
|
|
1416
1677
|
await performAction({ type: "record", params: {} }, async () => {
|
|
@@ -1425,14 +1686,62 @@ async function createHuman(page, options = {}) {
|
|
|
1425
1686
|
throw error;
|
|
1426
1687
|
} finally {
|
|
1427
1688
|
activeRecordingEvents = null;
|
|
1689
|
+
activeRecordingCaptureInputs = false;
|
|
1428
1690
|
}
|
|
1429
1691
|
const captureResult = captureSession ? await captureSession.stop() : null;
|
|
1430
1692
|
return new Recording(captureResult, windowStartMs, windowEndMs, {
|
|
1693
|
+
name: recordOptions.name,
|
|
1431
1694
|
personality: personality.name,
|
|
1432
1695
|
seed: options.seed === void 0 ? null : String(options.seed),
|
|
1433
1696
|
speed,
|
|
1434
1697
|
events
|
|
1435
1698
|
});
|
|
1699
|
+
},
|
|
1700
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1701
|
+
// Thin re-exports of common Playwright `Page` methods. See the `Human`
|
|
1702
|
+
// interface for the rationale; implementations forward unchanged.
|
|
1703
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1704
|
+
screenshot(opts) {
|
|
1705
|
+
return page.screenshot(opts);
|
|
1706
|
+
},
|
|
1707
|
+
pageText() {
|
|
1708
|
+
return page.innerText("body");
|
|
1709
|
+
},
|
|
1710
|
+
content() {
|
|
1711
|
+
return page.content();
|
|
1712
|
+
},
|
|
1713
|
+
url() {
|
|
1714
|
+
return page.url();
|
|
1715
|
+
},
|
|
1716
|
+
title() {
|
|
1717
|
+
return page.title();
|
|
1718
|
+
},
|
|
1719
|
+
async reload(opts) {
|
|
1720
|
+
await performAction({ type: "reload", params: {} }, async () => {
|
|
1721
|
+
await page.reload(opts);
|
|
1722
|
+
});
|
|
1723
|
+
},
|
|
1724
|
+
async goBack(opts) {
|
|
1725
|
+
await performAction({ type: "goBack", params: {} }, async () => {
|
|
1726
|
+
await page.goBack(opts);
|
|
1727
|
+
});
|
|
1728
|
+
},
|
|
1729
|
+
async goForward(opts) {
|
|
1730
|
+
await performAction({ type: "goForward", params: {} }, async () => {
|
|
1731
|
+
await page.goForward(opts);
|
|
1732
|
+
});
|
|
1733
|
+
},
|
|
1734
|
+
waitForLoadState(state, opts) {
|
|
1735
|
+
return page.waitForLoadState(state, opts);
|
|
1736
|
+
},
|
|
1737
|
+
waitForURL(url, opts) {
|
|
1738
|
+
return page.waitForURL(url, opts);
|
|
1739
|
+
},
|
|
1740
|
+
setViewportSize(size) {
|
|
1741
|
+
return page.setViewportSize(size);
|
|
1742
|
+
},
|
|
1743
|
+
pdf(opts) {
|
|
1744
|
+
return page.pdf(opts);
|
|
1436
1745
|
}
|
|
1437
1746
|
};
|
|
1438
1747
|
}
|