@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 CHANGED
@@ -341,7 +341,32 @@ await rec.toTimeline('session.json'); // → JSON on disk
341
341
  const timeline = rec.timeline; // → in-memory object
342
342
  ```
343
343
 
344
- 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.
345
370
 
346
371
  **Quality presets** trade off file size, encoding time, and visual fidelity. Defaults to `'high'`:
347
372
 
package/dist/index.cjs CHANGED
@@ -717,6 +717,205 @@ async function detectKindFromTag(locator) {
717
717
  if (tag === "pre" || tag === "code") return "code";
718
718
  return void 0;
719
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
720
919
  var pendingFrameCleanups = /* @__PURE__ */ new Set();
721
920
  var exitHandlerInstalled = false;
722
921
  function ensureExitHandler() {
@@ -812,6 +1011,7 @@ var Recording = class {
812
1011
  get timeline() {
813
1012
  return {
814
1013
  version: 1,
1014
+ ...this.#timelineSource.name !== void 0 ? { name: this.#timelineSource.name } : {},
815
1015
  personality: this.#timelineSource.personality,
816
1016
  seed: this.#timelineSource.seed,
817
1017
  speed: this.#timelineSource.speed,
@@ -962,6 +1162,38 @@ var Recording = class {
962
1162
  `, "utf8");
963
1163
  return outputPath;
964
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
+ }
965
1197
  /**
966
1198
  * Releases the captured-frames temp directory. After this call, `toVideo()`
967
1199
  * and `toGif()` throw — but `toTimeline()` and the in-memory `timeline`
@@ -1233,7 +1465,8 @@ async function createHuman(page, options = {}) {
1233
1465
  let hasRecorded = false;
1234
1466
  let activeRecordingEvents = null;
1235
1467
  let activeRecordingStartMs = 0;
1236
- async function performAction(action, actionFn) {
1468
+ let activeRecordingCaptureInputs = false;
1469
+ async function performAction(action, actionFn, recordMeta) {
1237
1470
  for (const plugin of plugins) {
1238
1471
  await plugin.beforeAction?.(action);
1239
1472
  }
@@ -1247,7 +1480,8 @@ async function createHuman(page, options = {}) {
1247
1480
  type: action.type,
1248
1481
  params: action.params ?? {},
1249
1482
  tMs: startedAt - activeRecordingStartMs,
1250
- durationMs
1483
+ durationMs,
1484
+ ...recordMeta?.inputValue !== void 0 ? { inputValue: recordMeta.inputValue } : {}
1251
1485
  });
1252
1486
  }
1253
1487
  for (const plugin of plugins) {
@@ -1261,7 +1495,8 @@ async function createHuman(page, options = {}) {
1261
1495
  params: action.params ?? {},
1262
1496
  tMs: startedAt - activeRecordingStartMs,
1263
1497
  durationMs: Date.now() - startedAt,
1264
- 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 } : {}
1265
1500
  });
1266
1501
  }
1267
1502
  for (const plugin of plugins) {
@@ -1288,6 +1523,21 @@ async function createHuman(page, options = {}) {
1288
1523
  }
1289
1524
  return target.toString?.() ?? "locator";
1290
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
+ };
1291
1541
  return {
1292
1542
  personality,
1293
1543
  speed,
@@ -1329,6 +1579,7 @@ async function createHuman(page, options = {}) {
1329
1579
  },
1330
1580
  async type(target, value) {
1331
1581
  const description = describeMouseTarget(target);
1582
+ const inputValue = await captureInputValue(target, value);
1332
1583
  await performAction(
1333
1584
  { type: "type", params: { target: description, length: value.length } },
1334
1585
  async () => {
@@ -1336,11 +1587,13 @@ async function createHuman(page, options = {}) {
1336
1587
  await executeClick(target, mouseCtx());
1337
1588
  }
1338
1589
  await executeType(target, value, { page, personality, rng, speed });
1339
- }
1590
+ },
1591
+ inputValue !== void 0 ? { inputValue } : void 0
1340
1592
  );
1341
1593
  },
1342
1594
  async paste(target, value) {
1343
1595
  const description = describeMouseTarget(target);
1596
+ const inputValue = await captureInputValue(target, value);
1344
1597
  await performAction(
1345
1598
  { type: "paste", params: { target: description, length: value.length } },
1346
1599
  async () => {
@@ -1348,7 +1601,8 @@ async function createHuman(page, options = {}) {
1348
1601
  await executeClick(target, mouseCtx());
1349
1602
  }
1350
1603
  await executePaste(target, value, { page, personality, rng, speed });
1351
- }
1604
+ },
1605
+ inputValue !== void 0 ? { inputValue } : void 0
1352
1606
  );
1353
1607
  },
1354
1608
  async press(key) {
@@ -1417,6 +1671,7 @@ async function createHuman(page, options = {}) {
1417
1671
  const windowStartMs = Date.now();
1418
1672
  activeRecordingEvents = events;
1419
1673
  activeRecordingStartMs = windowStartMs;
1674
+ activeRecordingCaptureInputs = recordOptions.captureInputs !== false;
1420
1675
  let windowEndMs = windowStartMs;
1421
1676
  try {
1422
1677
  await performAction({ type: "record", params: {} }, async () => {
@@ -1431,9 +1686,11 @@ async function createHuman(page, options = {}) {
1431
1686
  throw error;
1432
1687
  } finally {
1433
1688
  activeRecordingEvents = null;
1689
+ activeRecordingCaptureInputs = false;
1434
1690
  }
1435
1691
  const captureResult = captureSession ? await captureSession.stop() : null;
1436
1692
  return new Recording(captureResult, windowStartMs, windowEndMs, {
1693
+ name: recordOptions.name,
1437
1694
  personality: personality.name,
1438
1695
  seed: options.seed === void 0 ? null : String(options.seed),
1439
1696
  speed,