@humanjs/playwright 0.2.0 → 0.4.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
@@ -20,15 +20,242 @@ const browser = await chromium.launch();
20
20
  const page = await browser.newPage();
21
21
 
22
22
  const human = await createHuman(page, {
23
- personality: 'careful', // careful | fast | distracted | precise
24
- seed: 'session-42', // deterministic for tests
25
- speed: 'human', // human | fast | instant
23
+ personality: 'careful', // careful | fast | distracted | precise
24
+ seed: 'session-42', // deterministic for tests
25
+ speed: 'human', // human | fast | instant
26
26
  });
27
27
 
28
28
  await human.goto('https://example.com');
29
+
30
+ // Mouse: real Bezier path, velocity profile, pre-click hover dwell.
31
+ await human.click('button:has-text("Sign in")');
32
+
33
+ // Keyboard: per-key rhythm, optional QWERTY typos, Backspace recovery,
34
+ // occasional mid-word think pauses. The typed string is *not* echoed to
35
+ // plugin params — `params.length` only, by design.
36
+ await human.type('input[name="email"]', 'gonzalo@example.com');
37
+ ```
38
+
39
+ ### Speed modes
40
+
41
+ - `'human'` (default) — full humanization on every action.
42
+ - `'fast'` — humanized but accelerated.
43
+ - `'instant'` — bypass humanization entirely; uses Playwright's native methods. Per-key events still fire for `type()`. Right for CI.
44
+
45
+ ### Determinism
46
+
47
+ Pass a `seed` and every random decision (path curvature, typo placement, keystroke jitter) becomes reproducible. Same seed + same personality + same value = same keystrokes.
48
+
49
+ ### Reading
50
+
51
+ ```ts
52
+ await human.read('p.welcome');
53
+ ```
54
+
55
+ `human.read()` dwells like a real reader — pause-time scaled by the target's word count and the personality's reading WPM (with personality-controlled jitter).
56
+
57
+ **Target options:**
58
+
59
+ - `string` — Playwright-compatible selector
60
+ - `Locator` — a pre-built Locator
61
+ - `{ text: '...' }` — literal text, no DOM lookup
62
+ - `{ words: 42 }` — pre-counted; skips text extraction entirely
63
+
64
+ **Reading kinds** scale the dwell on top of `personality.reading.wpm`:
65
+
66
+ - `'prose'` (1.0×) — default for non-code targets
67
+ - `'code'` (0.4×) — slower; auto-detected when the target is a `<pre>` or `<code>` element
68
+ - `'scan'` (1.8×) — explicit skim mode
69
+
70
+ ```ts
71
+ await human.read('.article-body'); // prose, default
72
+ await human.read('pre.snippet'); // 'code' auto-detected from <pre>
73
+ await human.read('ul.changelog', { kind: 'scan' }); // explicit skim
74
+ ```
75
+
76
+ Explicit `kind` always wins over auto-detection.
77
+
78
+ **Eye-scan cursor motion** runs during the dwell by default:
79
+
80
+ ```ts
81
+ await human.read('article'); // motion: on
82
+ await human.read('article', { withMotion: false }); // motion: off
83
+ ```
84
+
85
+ The cursor walks a humanized L→R sweep through every line of rendered text and emits a small return-saccade between lines — same `mousemove` events a real reader would dispatch (so reading-time tooltip / hover handlers fire). Pass `{ withMotion: false }` when you only care about the temporal pattern (typical AI-agent use case).
86
+
87
+ For demos and screen recordings, pair it with `installMouseHelper(page)` to render a visible cursor that follows the synthetic motion:
88
+
89
+ ```ts
90
+ import { createHuman, installMouseHelper } from '@humanjs/playwright';
91
+
92
+ const page = await context.newPage();
93
+ await page.goto('https://example.com/article');
94
+ await installMouseHelper(page);
95
+
96
+ const human = await createHuman(page, { personality: 'careful' });
97
+ await human.read('article');
98
+ ```
99
+
100
+ **Returns** a `ReadResult`:
101
+
102
+ ```ts
103
+ const { words, durationMs, kind } = await human.read('main');
104
+ ```
105
+
106
+ Useful for test assertions or surfacing reading metadata in a UI.
107
+
108
+ **Privacy**: the read text is never echoed to plugin params. `read` actions surface only `{ target, kind }` plus inert length metadata — the content itself stays out of telemetry by design, same posture as `human.type()`.
109
+
110
+ ### Scrolling
111
+
112
+ ```ts
113
+ await human.scroll(); // ~one viewport down, humanized
114
+ ```
115
+
116
+ `human.scroll()` produces multi-segment scroll motion with a bell-curve velocity profile (slow start, fast middle, slow end), optional mid-scroll micro-pauses, and — for the `distracted` personality — occasional overshoot + correction. Page scrolls dispatch real `wheel` events; container scrolls advance the element's scroll position directly (more reliable inside nested overflow containers).
117
+
118
+ **Target options:**
119
+
120
+ ```ts
121
+ await human.scroll(); // 'natural' — ~one viewport
122
+ await human.scroll('top'); // to the top
123
+ await human.scroll('end'); // to the bottom
124
+ await human.scroll({ by: 800 }); // relative pixel delta (negative = up)
125
+ await human.scroll({ to: 1500 }); // absolute scroll position on the chosen axis
126
+ await human.scroll('#pricing'); // by selector — scroll until in view
127
+ await human.scroll(locator); // by Locator
128
+ ```
129
+
130
+ **Element-target alignment** matches native `scrollIntoView`:
131
+
132
+ ```ts
133
+ await human.scroll('#hero', { block: 'center' }); // 'start' | 'center' | 'end' | 'nearest'
134
+ ```
135
+
136
+ `'nearest'` is a useful default for "make sure this element is visible without moving more than necessary" — it stays put if the element is already fully in view, otherwise scrolls to the closest edge.
137
+
138
+ **Scroll inside a scrollable container**, not the page:
139
+
140
+ ```ts
141
+ await human.scroll('end', { within: '#messages' }); // chat thread to latest
142
+ await human.scroll('#newest-item', { within: '.feed', block: 'end' });
143
+ await human.scroll({ by: -200 }, { within: modalBody }); // scroll up inside a modal
29
144
  ```
30
145
 
31
- See [humanjs.dev](https://humanjs.dev) for full documentation.
146
+ Every target shape (`'natural'`, `'top'`, `'end'`, selectors, `{ by }`, `{ to }`) applies relative to the container. In humanized mode the cursor parks over the container's center (so an `installMouseHelper` overlay reads as "human hand on the wheel") and each segment advances the container's `scrollLeft` / `scrollTop` directly — more reliable than wheel events inside nested overflow containers. In `'instant'` mode the container's scroll position is set with a single `scrollTo` call.
147
+
148
+ **Horizontal scroll** via `axis: 'x'` — same target shapes apply to the X axis:
149
+
150
+ ```ts
151
+ await human.scroll('end', { axis: 'x' }); // to the right edge
152
+ await human.scroll({ by: 400 }, { axis: 'x' }); // 400px right
153
+ await human.scroll('#card-5', { axis: 'x', block: 'center' }); // carousel to a card
154
+ await human.scroll('end', { within: '#kanban', axis: 'x' }); // kanban board to the right end
155
+ ```
156
+
157
+ Defaults to `'y'`. Combine with `within` for horizontal scrolling inside a container (carousels, kanban boards, sideways galleries).
158
+
159
+ **Force overshoot** even when the personality wouldn't choose one — useful for demos and screen recordings where the humanization signal needs to read clearly:
160
+
161
+ ```ts
162
+ await human.scroll('#footer', { overshoot: true });
163
+ ```
164
+
165
+ **Returns** a `ScrollResult`:
166
+
167
+ ```ts
168
+ const { from, to, distance, durationMs } = await human.scroll('end');
169
+ ```
170
+
171
+ In `speed: 'instant'`, the page jumps directly via `window.scrollTo` — no wheel events — but the action still fires for observability.
172
+
173
+ See [humanjs.dev](https://humanjs.dev) for the full feature set and personality reference.
174
+
175
+ ### Recording
176
+
177
+ ```ts
178
+ import { chromium, createHuman, installMouseHelper } from '@humanjs/playwright';
179
+
180
+ const browser = await chromium.launch({ headless: false });
181
+ const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
182
+ const page = await context.newPage();
183
+
184
+ // Visible cursor overlay so the recorded video shows mouse motion.
185
+ await installMouseHelper(context);
186
+
187
+ const human = await createHuman(page);
188
+
189
+ const rec = await human.record(async () => {
190
+ await human.click('#login');
191
+ await human.type('#email', 'demo@humanjs.dev');
192
+ });
193
+
194
+ await rec.toVideo('demo.mp4');
195
+ await rec.toTimeline('demo.json');
196
+ await browser.close();
197
+ ```
198
+
199
+ `human.record(cb)` polls `page.screenshot()` at the target FPS, writes each frame to a temp directory, then assembles them via ffmpeg when you call an exporter:
200
+
201
+ - `rec.toVideo(path)` — `.mp4` (H.264 / yuv420p) or `.webm` (VP9)
202
+ - `rec.toGif(path, { fps?, width? })` — palette-optimized animated GIF (`palettegen` + `paletteuse`, Bayer dither). Defaults to 15 fps, source viewport size.
203
+
204
+ Both exporters are **repeatable and interleavable** — they read the captured frames, they don't consume them. Want an mp4 for the landing page *and* a GIF for the README from the same recording? Just call both:
205
+
206
+ ```ts
207
+ const rec = await human.record(fn);
208
+ await rec.toVideo('demo.mp4');
209
+ await rec.toGif('demo.gif', { width: 720 });
210
+ // No explicit cleanup needed for one-shot scripts — see below.
211
+ ```
212
+
213
+ Captured frames live in a temp directory under `os.tmpdir()`. Cleanup happens automatically at process exit (a single `process.on('exit')` handler sweeps any un-disposed frame dirs), so casual scripts don't have to think about it. For long-running services, batch jobs, or anywhere you want predictable disk usage, release them proactively:
214
+
215
+ ```ts
216
+ await rec.dispose(); // explicit, idempotent
217
+ // or with TS ≥ 5.2 / Node ≥ 20.4:
218
+ await using rec = await human.record(fn); // auto-disposes at scope exit
219
+ ```
220
+
221
+ The same `Recording` exposes a **structured action timeline** of everything that happened during the callback:
222
+
223
+ ```ts
224
+ await rec.toTimeline('session.json'); // → JSON on disk
225
+ const timeline = rec.timeline; // → in-memory object
226
+ ```
227
+
228
+ 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.
229
+
230
+ **Quality presets** trade off file size, encoding time, and visual fidelity. Defaults to `'high'`:
231
+
232
+ ```ts
233
+ await rec.toVideo('demo.mp4', { quality: 'high' });
234
+ // 'fast' — JPEG q=85, CRF 23, preset fast (iteration)
235
+ // 'standard' — JPEG q=90, CRF 20, preset fast (balanced)
236
+ // 'high' — JPEG q=95, CRF 18, preset slow, animation (DEFAULT)
237
+ // 'lossless' — PNG capture, CRF 12, preset veryslow (archival)
238
+ ```
239
+
240
+ Individual ffmpeg knobs (`crf`, `preset`, `tune`) can override the preset for fine-grained control.
241
+
242
+ **Timeline-only mode** — skip the capture overhead entirely when you only need the action timeline:
243
+
244
+ ```ts
245
+ const rec = await human.record({ video: false }, async () => {
246
+ await human.click('#login');
247
+ });
248
+ await rec.toTimeline('session.json'); // works
249
+ // rec.toVideo('demo.mp4') // throws with a clear message
250
+ ```
251
+
252
+ **Lifecycle notes**:
253
+
254
+ - Each session can produce **one** recording. `human.record()` throws if called twice on the same session — open a new context (and a new human) to record a separate clip.
255
+ - `Recording.toVideo()` / `Recording.toGif()` are repeatable and interleavable. Frames live until `rec.dispose()` (or `await using` goes out of scope, or the process exits — a sweep-on-exit handler covers forgotten disposes).
256
+ - For a one-call API that owns the entire lifecycle (launch → record → close), use [`@humanjs/recorder`](../recorder)'s `record(options, fn)` instead.
257
+
258
+ Every recording is a regular plugin action — `beforeAction` and `afterAction` observe `{ type: 'record' }` exactly like `'click'` or `'scroll'`.
32
259
 
33
260
  ## License
34
261