@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/dist/index.js CHANGED
@@ -320,15 +320,22 @@ function clamp(value, min, max) {
320
320
  // src/mouse/index.ts
321
321
  async function executeClick(target, ctx, options = {}) {
322
322
  const button = options.button ?? "left";
323
- const locator = typeof target === "string" ? ctx.page.locator(target) : target;
324
323
  if (ctx.speed === "instant") {
325
- const box = await locator.boundingBox();
324
+ if (isPoint(target)) {
325
+ await ctx.page.mouse.click(target.x, target.y, { button });
326
+ ctx.setMousePosition(target);
327
+ return { target };
328
+ }
329
+ const locator = typeof target === "string" ? ctx.page.locator(target) : target;
330
+ const box2 = await locator.boundingBox();
326
331
  await locator.click({ button });
327
- const center = box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : ctx.getMousePosition();
332
+ const center = box2 ? { x: box2.x + box2.width / 2, y: box2.y + box2.height / 2 } : ctx.getMousePosition();
328
333
  ctx.setMousePosition(center);
329
334
  return { target: center };
330
335
  }
331
- const targetPoint = await moveToTarget(target, ctx, "click");
336
+ const { point: targetPoint, box } = await resolveTargetPointAndBox(target, ctx, "click");
337
+ await maybeMisclickBeat(ctx, box, targetPoint);
338
+ await walkBezierTo(targetPoint, ctx);
332
339
  const preClickMs = computeDwellTime(
333
340
  ctx.personality.dwell.preClickMs,
334
341
  ctx.personality.dwell.preClickJitter,
@@ -357,7 +364,7 @@ async function executeHover(target, ctx) {
357
364
  ctx.setMousePosition(center);
358
365
  return { target: center };
359
366
  }
360
- const targetPoint = await moveToTarget(target, ctx, "hover");
367
+ const targetPoint = await moveToTarget(target, ctx);
361
368
  const dwellMs = computeDwellTime(
362
369
  ctx.personality.dwell.preClickMs,
363
370
  ctx.personality.dwell.preClickJitter,
@@ -440,10 +447,9 @@ async function executeMove(target, ctx) {
440
447
  ctx.setMousePosition(point);
441
448
  return { target: point };
442
449
  }
443
- async function moveToTarget(target, ctx, action) {
444
- const box = await readBoxWithAutoScroll(target, ctx, action);
450
+ async function moveToTarget(target, ctx) {
451
+ const box = await readBoxWithAutoScroll(target, ctx, "hover");
445
452
  const targetPoint = pickClickPoint(box, ctx.rng, ctx.personality.mouse.clickSpread);
446
- if (action === "click") await maybeMisclickBeat(ctx, box, targetPoint);
447
453
  await walkBezierTo(targetPoint, ctx);
448
454
  return targetPoint;
449
455
  }
@@ -706,6 +712,205 @@ async function detectKindFromTag(locator) {
706
712
  if (tag === "pre" || tag === "code") return "code";
707
713
  return void 0;
708
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
709
914
  var pendingFrameCleanups = /* @__PURE__ */ new Set();
710
915
  var exitHandlerInstalled = false;
711
916
  function ensureExitHandler() {
@@ -801,6 +1006,7 @@ var Recording = class {
801
1006
  get timeline() {
802
1007
  return {
803
1008
  version: 1,
1009
+ ...this.#timelineSource.name !== void 0 ? { name: this.#timelineSource.name } : {},
804
1010
  personality: this.#timelineSource.personality,
805
1011
  seed: this.#timelineSource.seed,
806
1012
  speed: this.#timelineSource.speed,
@@ -951,6 +1157,38 @@ var Recording = class {
951
1157
  `, "utf8");
952
1158
  return outputPath;
953
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
+ }
954
1192
  /**
955
1193
  * Releases the captured-frames temp directory. After this call, `toVideo()`
956
1194
  * and `toGif()` throw — but `toTimeline()` and the in-memory `timeline`
@@ -1222,7 +1460,8 @@ async function createHuman(page, options = {}) {
1222
1460
  let hasRecorded = false;
1223
1461
  let activeRecordingEvents = null;
1224
1462
  let activeRecordingStartMs = 0;
1225
- async function performAction(action, actionFn) {
1463
+ let activeRecordingCaptureInputs = false;
1464
+ async function performAction(action, actionFn, recordMeta) {
1226
1465
  for (const plugin of plugins) {
1227
1466
  await plugin.beforeAction?.(action);
1228
1467
  }
@@ -1236,7 +1475,8 @@ async function createHuman(page, options = {}) {
1236
1475
  type: action.type,
1237
1476
  params: action.params ?? {},
1238
1477
  tMs: startedAt - activeRecordingStartMs,
1239
- durationMs
1478
+ durationMs,
1479
+ ...recordMeta?.inputValue !== void 0 ? { inputValue: recordMeta.inputValue } : {}
1240
1480
  });
1241
1481
  }
1242
1482
  for (const plugin of plugins) {
@@ -1250,7 +1490,8 @@ async function createHuman(page, options = {}) {
1250
1490
  params: action.params ?? {},
1251
1491
  tMs: startedAt - activeRecordingStartMs,
1252
1492
  durationMs: Date.now() - startedAt,
1253
- 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 } : {}
1254
1495
  });
1255
1496
  }
1256
1497
  for (const plugin of plugins) {
@@ -1277,6 +1518,21 @@ async function createHuman(page, options = {}) {
1277
1518
  }
1278
1519
  return target.toString?.() ?? "locator";
1279
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
+ };
1280
1536
  return {
1281
1537
  personality,
1282
1538
  speed,
@@ -1318,6 +1574,7 @@ async function createHuman(page, options = {}) {
1318
1574
  },
1319
1575
  async type(target, value) {
1320
1576
  const description = describeMouseTarget(target);
1577
+ const inputValue = await captureInputValue(target, value);
1321
1578
  await performAction(
1322
1579
  { type: "type", params: { target: description, length: value.length } },
1323
1580
  async () => {
@@ -1325,11 +1582,13 @@ async function createHuman(page, options = {}) {
1325
1582
  await executeClick(target, mouseCtx());
1326
1583
  }
1327
1584
  await executeType(target, value, { page, personality, rng, speed });
1328
- }
1585
+ },
1586
+ inputValue !== void 0 ? { inputValue } : void 0
1329
1587
  );
1330
1588
  },
1331
1589
  async paste(target, value) {
1332
1590
  const description = describeMouseTarget(target);
1591
+ const inputValue = await captureInputValue(target, value);
1333
1592
  await performAction(
1334
1593
  { type: "paste", params: { target: description, length: value.length } },
1335
1594
  async () => {
@@ -1337,7 +1596,8 @@ async function createHuman(page, options = {}) {
1337
1596
  await executeClick(target, mouseCtx());
1338
1597
  }
1339
1598
  await executePaste(target, value, { page, personality, rng, speed });
1340
- }
1599
+ },
1600
+ inputValue !== void 0 ? { inputValue } : void 0
1341
1601
  );
1342
1602
  },
1343
1603
  async press(key) {
@@ -1406,6 +1666,7 @@ async function createHuman(page, options = {}) {
1406
1666
  const windowStartMs = Date.now();
1407
1667
  activeRecordingEvents = events;
1408
1668
  activeRecordingStartMs = windowStartMs;
1669
+ activeRecordingCaptureInputs = recordOptions.captureInputs !== false;
1409
1670
  let windowEndMs = windowStartMs;
1410
1671
  try {
1411
1672
  await performAction({ type: "record", params: {} }, async () => {
@@ -1420,14 +1681,62 @@ async function createHuman(page, options = {}) {
1420
1681
  throw error;
1421
1682
  } finally {
1422
1683
  activeRecordingEvents = null;
1684
+ activeRecordingCaptureInputs = false;
1423
1685
  }
1424
1686
  const captureResult = captureSession ? await captureSession.stop() : null;
1425
1687
  return new Recording(captureResult, windowStartMs, windowEndMs, {
1688
+ name: recordOptions.name,
1426
1689
  personality: personality.name,
1427
1690
  seed: options.seed === void 0 ? null : String(options.seed),
1428
1691
  speed,
1429
1692
  events
1430
1693
  });
1694
+ },
1695
+ // ────────────────────────────────────────────────────────────────────
1696
+ // Thin re-exports of common Playwright `Page` methods. See the `Human`
1697
+ // interface for the rationale; implementations forward unchanged.
1698
+ // ────────────────────────────────────────────────────────────────────
1699
+ screenshot(opts) {
1700
+ return page.screenshot(opts);
1701
+ },
1702
+ pageText() {
1703
+ return page.innerText("body");
1704
+ },
1705
+ content() {
1706
+ return page.content();
1707
+ },
1708
+ url() {
1709
+ return page.url();
1710
+ },
1711
+ title() {
1712
+ return page.title();
1713
+ },
1714
+ async reload(opts) {
1715
+ await performAction({ type: "reload", params: {} }, async () => {
1716
+ await page.reload(opts);
1717
+ });
1718
+ },
1719
+ async goBack(opts) {
1720
+ await performAction({ type: "goBack", params: {} }, async () => {
1721
+ await page.goBack(opts);
1722
+ });
1723
+ },
1724
+ async goForward(opts) {
1725
+ await performAction({ type: "goForward", params: {} }, async () => {
1726
+ await page.goForward(opts);
1727
+ });
1728
+ },
1729
+ waitForLoadState(state, opts) {
1730
+ return page.waitForLoadState(state, opts);
1731
+ },
1732
+ waitForURL(url, opts) {
1733
+ return page.waitForURL(url, opts);
1734
+ },
1735
+ setViewportSize(size) {
1736
+ return page.setViewportSize(size);
1737
+ },
1738
+ pdf(opts) {
1739
+ return page.pdf(opts);
1431
1740
  }
1432
1741
  };
1433
1742
  }