@specific.dev/spectest 0.117.0 → 0.119.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/browser.d.ts CHANGED
@@ -65,7 +65,7 @@ export interface BrowserSessionStep {
65
65
  /** rrweb events as emitted by `rrweb.record`'s `emit` callback. */
66
66
  events: unknown[];
67
67
  }
68
- import type { BrowserAction } from "./recorder.js";
68
+ import type { BrowserAction, RecordedBrowserLocatorPart } from "./recorder.js";
69
69
  export type { BrowserAction } from "./recorder.js";
70
70
  /** Sink the daemon passes in to collect per-session rrweb event chunks. */
71
71
  export interface BrowserSessionRecorder {
@@ -363,6 +363,8 @@ export declare function acquirePersistentDesktop(endpoint: string, windowIndex:
363
363
  desktopChrome?: DesktopChrome;
364
364
  }>;
365
365
  export interface RecordableFields {
366
+ target: RecordedBrowserLocatorPart[];
367
+ destinationTarget: RecordedBrowserLocatorPart[];
366
368
  url: string;
367
369
  selector: string;
368
370
  description: string;
@@ -381,4 +383,11 @@ export interface RecordableFields {
381
383
  attribute: string;
382
384
  files: string[];
383
385
  targetNodeId: number;
386
+ checked: boolean;
387
+ values: string | string[] | {
388
+ label?: string;
389
+ value?: string;
390
+ index?: number;
391
+ };
392
+ state: string;
384
393
  }
package/dist/browser.js CHANGED
@@ -1449,7 +1449,7 @@ function buildBackend(holder, recorder, buildOpts) {
1449
1449
  insertText(text) {
1450
1450
  // insertText path (no per-char keydown) — the paste path.
1451
1451
  const t = truncateUtf8(text);
1452
- return instrumented("type", { text: t.value, textTruncated: t.truncated }, () => holder.page.keyboard.insertText(text));
1452
+ return instrumented("insertText", { text: t.value, textTruncated: t.truncated }, () => holder.page.keyboard.insertText(text));
1453
1453
  },
1454
1454
  };
1455
1455
  const mouse = {
@@ -0,0 +1,18 @@
1
+ export interface NetworkBinding {
2
+ instanceId: string;
3
+ token: string;
4
+ }
5
+ type Handler = (params: Record<string, unknown>) => Promise<unknown>;
6
+ export declare function serveNetwork(registry: Record<string, Handler>, options: {
7
+ bindingPath: string;
8
+ hostname?: string;
9
+ port?: number;
10
+ resolverPath?: string;
11
+ resultLimits?: {
12
+ count?: number;
13
+ bytes?: number;
14
+ ttlMs?: number;
15
+ };
16
+ dispatch?: (method: string, params: Record<string, unknown>, timeoutMs?: number) => Promise<unknown>;
17
+ }): Bun.Server<undefined>;
18
+ export {};
@@ -0,0 +1,194 @@
1
+ /** Experimental cloud transport. No VM agent or control-plane credential lives here.
2
+ * Read the binding on every request: checkpoint children inherit the process heap,
3
+ * but receive a new binding file before the controller dispatches work to them.
4
+ * Future guest-initiated forks must perform the same rebind before accepting work.
5
+ */
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { timingSafeEqual } from "node:crypto";
8
+ import { codeOf } from "./methods";
9
+ import { describeThrow } from "./protocol";
10
+ export function serveNetwork(registry, options) {
11
+ let session = "";
12
+ const seen = new Set();
13
+ let active = 0;
14
+ const results = new Map();
15
+ const maxResults = options.resultLimits?.count ?? 128;
16
+ const maxBytes = options.resultLimits?.bytes ?? 64 * 1024 * 1024;
17
+ const ttlMs = options.resultLimits?.ttlMs ?? 10 * 60_000;
18
+ let resultBytes = 0;
19
+ const forget = (id) => {
20
+ resultBytes -= results.get(id)?.bytes ?? 0;
21
+ results.delete(id);
22
+ };
23
+ const expire = () => {
24
+ for (const [id, result] of results)
25
+ if (result.completedAt !== undefined && Date.now() - result.completedAt >= ttlMs)
26
+ forget(id);
27
+ };
28
+ const reply = (body, status = 200) => Response.json(body, {
29
+ status, headers: { "Cache-Control": "no-store" },
30
+ });
31
+ return Bun.serve({
32
+ hostname: options.hostname ?? "0.0.0.0",
33
+ port: options.port ?? 4318,
34
+ maxRequestBodySize: 16 * 1024 * 1024,
35
+ async fetch(req, server) {
36
+ const url = new URL(req.url);
37
+ if (url.pathname !== "/rpc" || !["POST", "GET", "DELETE"].includes(req.method))
38
+ return reply({ error: "not_found" }, 404);
39
+ let binding;
40
+ try {
41
+ binding = JSON.parse(readFileSync(options.bindingPath, "utf8"));
42
+ if (!binding.instanceId || !binding.token || binding.token.length < 32)
43
+ throw Error();
44
+ }
45
+ catch {
46
+ return reply({ error: "unbound" }, 503);
47
+ }
48
+ const supplied = Buffer.from(req.headers.get("X-Spectest-Harness-Authorization") ?? req.headers.get("Authorization") ?? "");
49
+ const expected = Buffer.from(`Bearer ${binding.token}`);
50
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
51
+ return reply({ error: "unauthorized" }, 401);
52
+ if (req.headers.get("X-Spectest-Instance") !== binding.instanceId)
53
+ return reply({ error: "wrong_instance" }, 409);
54
+ // Neither parent request IDs nor network connections are child identity.
55
+ const nextSession = `${binding.instanceId}:${binding.token}`;
56
+ if (session !== nextSession) {
57
+ if (active)
58
+ return reply({ error: "restore_during_request" }, 409);
59
+ session = nextSession;
60
+ seen.clear();
61
+ results.clear();
62
+ resultBytes = 0;
63
+ }
64
+ expire();
65
+ if (req.method !== "POST") {
66
+ const requestId = url.searchParams.get("requestId");
67
+ const envelope = { instanceId: binding.instanceId, requestId };
68
+ const result = requestId ? results.get(requestId) : undefined;
69
+ if (!result)
70
+ return reply({ ...envelope, error: "result_not_found" }, 404);
71
+ if (req.method === "GET") {
72
+ const waitMs = Number(url.searchParams.get("waitMs") ?? 0);
73
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 25_000)
74
+ return reply({ ...envelope, error: "invalid_wait" }, 400);
75
+ if (result.body === undefined && waitMs > 0 && !req.signal.aborted) {
76
+ server.timeout(req, 0);
77
+ // Disconnecting a waiter never cancels the operation. Remove its
78
+ // listener on every exit, including aborts and bounded timeouts.
79
+ await new Promise(resolve => {
80
+ const done = () => {
81
+ clearTimeout(timer);
82
+ result.waiters.delete(done);
83
+ req.signal.removeEventListener("abort", done);
84
+ resolve();
85
+ };
86
+ const timer = setTimeout(done, waitMs);
87
+ result.waiters.add(done);
88
+ req.signal.addEventListener("abort", done, { once: true });
89
+ if (req.signal.aborted)
90
+ done();
91
+ });
92
+ // A restored heap or a concurrent acknowledgement must not expose
93
+ // a stale result to a connection admitted under the old identity.
94
+ try {
95
+ const current = JSON.parse(readFileSync(options.bindingPath, "utf8"));
96
+ if (current.instanceId !== binding.instanceId || current.token !== binding.token)
97
+ return reply({ ...envelope, error: "stale_binding" }, 409);
98
+ }
99
+ catch {
100
+ return reply({ ...envelope, error: "unbound" }, 503);
101
+ }
102
+ if (results.get(requestId) !== result)
103
+ return reply({ ...envelope, error: "result_not_found" }, 404);
104
+ }
105
+ }
106
+ if (result.body === undefined)
107
+ return reply({ ...envelope, state: "pending", resultWait: true }, 202);
108
+ if (req.method === "DELETE") {
109
+ forget(requestId);
110
+ return reply({ ...envelope, state: "acknowledged" });
111
+ }
112
+ return new Response(result.body, { status: result.status,
113
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
114
+ }
115
+ let body;
116
+ try {
117
+ body = await req.json();
118
+ if (!body || typeof body.requestId !== "string" || body.requestId.length > 100 ||
119
+ !body.requestId || typeof body.method !== "string" ||
120
+ (body.async !== undefined && typeof body.async !== "boolean") ||
121
+ (body.timeoutMs !== undefined && (!Number.isSafeInteger(body.timeoutMs) || body.timeoutMs <= 0)) ||
122
+ (body.params !== undefined && (!body.params || typeof body.params !== "object" || Array.isArray(body.params))))
123
+ throw Error();
124
+ }
125
+ catch {
126
+ return reply({ error: "invalid_request" }, 400);
127
+ }
128
+ // A request body may have been in flight across a restore/rebind.
129
+ try {
130
+ const current = JSON.parse(readFileSync(options.bindingPath, "utf8"));
131
+ if (current.instanceId !== binding.instanceId || current.token !== binding.token)
132
+ return reply({ error: "stale_binding" }, 409);
133
+ }
134
+ catch {
135
+ return reply({ error: "unbound" }, 503);
136
+ }
137
+ const envelope = { instanceId: binding.instanceId, requestId: body.requestId };
138
+ if (seen.has(body.requestId))
139
+ return reply({ ...envelope, error: "duplicate_request" }, 409);
140
+ const handler = Object.hasOwn(registry, body.method) ? registry[body.method]
141
+ : options.dispatch ? (params) => options.dispatch(body.method, params, body.timeoutMs) : undefined;
142
+ if (!handler)
143
+ return reply({ ...envelope, error: "unimplemented" }, 400);
144
+ if (active >= 32)
145
+ return reply({ ...envelope, error: "too_many_requests" }, 429);
146
+ if (body.async && results.size >= maxResults)
147
+ return reply({ ...envelope, error: "result_capacity" }, 429);
148
+ seen.add(body.requestId);
149
+ if (seen.size > 4096)
150
+ seen.delete(seen.values().next().value);
151
+ // Long test calls are bounded by the caller and runner, not the HTTP idle timer.
152
+ server.timeout(req, 0);
153
+ active++;
154
+ const execute = async () => {
155
+ try {
156
+ // Unikraft rewrites the resolver on checkpoint restore AND native wake.
157
+ if (options.resolverPath) {
158
+ const resolver = "nameserver 127.0.0.53\noptions edns0\n";
159
+ if (readFileSync(options.resolverPath, "utf8") !== resolver)
160
+ writeFileSync(options.resolverPath, resolver);
161
+ }
162
+ return { body: JSON.stringify({ ...envelope, result: await handler(body.params ?? {}) }), status: 200 };
163
+ }
164
+ catch (error) {
165
+ return { body: JSON.stringify({ ...envelope, error: codeOf(error), message: describeThrow(error) }), status: 400 };
166
+ }
167
+ finally {
168
+ active--;
169
+ }
170
+ };
171
+ if (body.async) {
172
+ const result = { bytes: 0, waiters: new Set() };
173
+ results.set(body.requestId, result);
174
+ // Retain work and its outcome independently of any one HTTP connection.
175
+ void execute().then(completed => {
176
+ let bytes = Buffer.byteLength(completed.body);
177
+ if (resultBytes + bytes > maxBytes) {
178
+ completed = { body: JSON.stringify({ ...envelope, error: "result_too_large",
179
+ message: "Harness result storage limit exceeded; work must not be replayed" }), status: 507 };
180
+ bytes = Buffer.byteLength(completed.body);
181
+ }
182
+ Object.assign(result, completed, { bytes, completedAt: Date.now() });
183
+ resultBytes += bytes;
184
+ for (const done of result.waiters)
185
+ done();
186
+ });
187
+ return reply({ ...envelope, state: "pending", resultWait: true }, 202);
188
+ }
189
+ const completed = await execute();
190
+ return new Response(completed.body, { status: completed.status,
191
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
192
+ },
193
+ });
194
+ }
package/dist/locator.js CHANGED
@@ -51,6 +51,24 @@ export function isLocator(x) {
51
51
  function chainOf(loc) {
52
52
  return loc[CHAIN];
53
53
  }
54
+ /** Keep locator structure (including regex flags and nested filters) in the
55
+ * event. The old selector field remains for classic/CLI compatibility. */
56
+ function recordedChain(chain) {
57
+ const argument = (v) => {
58
+ if (v instanceof RegExp)
59
+ return { regex: v.source, flags: v.flags };
60
+ if (isLocator(v))
61
+ return recordedChain(chainOf(v));
62
+ if (Array.isArray(v))
63
+ return v.map(argument);
64
+ if (v && typeof v === "object") {
65
+ return Object.fromEntries(Object.entries(v).filter(([, value]) => value !== undefined)
66
+ .map(([key, value]) => [key, argument(value)]));
67
+ }
68
+ return v;
69
+ };
70
+ return chain.steps.map(({ m, args }) => ({ method: m, args: args.map(argument) }));
71
+ }
54
72
  // ────────────────────────────────────────────────────────────────────────
55
73
  // Lowering: chain → live playwright Locator
56
74
  // ────────────────────────────────────────────────────────────────────────
@@ -327,8 +345,8 @@ async function stampActionPoint(loc, rec, position) {
327
345
  /* Element not ready / gone / strict violation — the action reports it. */
328
346
  }
329
347
  }
330
- /** Stamp the rrweb node id of the element a settled `expect(locator)` step is
331
- * about, so the dashboard can bring it into the replay's view.
348
+ /** Stamp the rrweb node id of a locator operation's target, so the dashboard
349
+ * can highlight it and bring it into the replay's view.
332
350
  *
333
351
  * Playwright's idea of "visible" is a non-empty box that isn't hidden — it
334
352
  * says nothing about the viewport, so an element below the fold passes
@@ -346,9 +364,11 @@ async function stampActionPoint(loc, rec, position) {
346
364
  * leaves the step unstamped, which just means the viewer doesn't scroll. */
347
365
  async function stampRevealTarget(loc, rec) {
348
366
  try {
349
- const id = await loc.evaluate((el) => {
350
- if (window.top !== window)
367
+ const id = await loc.evaluateAll((elements) => {
368
+ // Never wait for a missing element or pick an arbitrary list match.
369
+ if (elements.length !== 1 || window.top !== window)
351
370
  return 0;
371
+ const el = elements[0];
352
372
  // The bootstrap stashes rrweb's `record` here; `mirror` is its
353
373
  // node ↔ id map, shared by every snapshot it takes (see browser.ts).
354
374
  const w = window;
@@ -357,7 +377,7 @@ async function stampRevealTarget(loc, rec) {
357
377
  return 0;
358
378
  const id = getId.call(w.__spectestRec.mirror, el);
359
379
  return typeof id === "number" && id > 0 ? id : 0;
360
- }, undefined, { timeout: POINT_PROBE_MS });
380
+ });
361
381
  if (id)
362
382
  rec.targetNodeId = id;
363
383
  }
@@ -395,6 +415,7 @@ function lowerInputFiles(files) {
395
415
  * through `backend.pageOp`. */
396
416
  export function makeLocator(backend, strategy, chain) {
397
417
  const label = chainLabel(chain);
418
+ const target = recordedChain(chain);
398
419
  const extend = (step) => makeLocator(backend, strategy, { steps: [...chain.steps, step] });
399
420
  /** The "did you mean…" block for a locator that matched nothing. Empty
400
421
  * for a chain that must not suggest (see {@link hintTargetFromChain}) and
@@ -419,18 +440,42 @@ export function makeLocator(backend, strategy, chain) {
419
440
  throw await rewriteLocatorErrorWithHints(err, label, () => hintBlock(page));
420
441
  }
421
442
  };
443
+ // Capture identity before mutations (e.g. a press that removes its target).
444
+ // If an action auto-waits for an element that is not present yet, capture it
445
+ // afterwards instead. Both probes are immediate and best-effort; annotation
446
+ // must not change action errors, return values or locator strictness.
447
+ const withTarget = async (page, action, rec, fn) => {
448
+ const lowered = lower(page, chain);
449
+ const singleTarget = !["count", "all", "allTextContents", "allInnerTexts"].includes(action)
450
+ && !(action === "waitFor" && (rec.state === "hidden" || rec.state === "detached"));
451
+ if (singleTarget)
452
+ await stampRevealTarget(lowered, rec);
453
+ try {
454
+ return await fn(lowered, page);
455
+ }
456
+ finally {
457
+ if (singleTarget && !rec.targetNodeId)
458
+ await stampRevealTarget(lowered, rec);
459
+ }
460
+ };
422
461
  // One recorded event, result NOT wrapped (void/action).
423
- const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(page, () => fn(lower(page, chain), page)));
462
+ const act = (action, fields, fn) => {
463
+ const rec = { selector: label, target, ...fields };
464
+ return backend.pageOp(action, rec, (page) => readable(page, () => withTarget(page, action, rec, fn)));
465
+ };
424
466
  // One recorded event whose fields the action itself finishes filling in:
425
467
  // `rec` is the very object the recorder spreads once `fn` resolves (the
426
468
  // same mutate-in-flight seam `screenshot`'s artifact id rides), so the
427
469
  // click family can stamp the point it acted on. See stampActionPoint.
428
470
  const actAt = (action, fn) => {
429
- const rec = { selector: label };
471
+ const rec = { selector: label, target };
430
472
  return backend.pageOp(action, rec, (page) => readable(page, () => fn(lower(page, chain), rec)));
431
473
  };
432
474
  // One recorded event, result provenance-wrapped for expect().
433
- const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => readable(page, () => fn(lower(page, chain))), { wrap: true });
475
+ const read = (action, fn, fields = {}) => {
476
+ const rec = { selector: label, target, ...fields };
477
+ return backend.pageOp(action, rec, (page) => readable(page, () => withTarget(page, action, rec, fn)), { wrap: true });
478
+ };
434
479
  // Silent, non-recorded reads for expect(locator) matchers to poll.
435
480
  const probe = {
436
481
  label,
@@ -445,7 +490,7 @@ export function makeLocator(backend, strategy, chain) {
445
490
  isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
446
491
  isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
447
492
  settle: async (action, waitedMs, error, opts) => {
448
- const fields = { selector: label };
493
+ const fields = { selector: label, target };
449
494
  if (opts && opts.reveal) {
450
495
  await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
451
496
  }
@@ -492,8 +537,8 @@ export function makeLocator(backend, strategy, chain) {
492
537
  },
493
538
  check: (opts) => act("check", {}, (l) => l.check({ timeout: opts?.timeout })),
494
539
  uncheck: (opts) => act("uncheck", {}, (l) => l.uncheck({ timeout: opts?.timeout })),
495
- setChecked: (checked, opts) => act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
496
- selectOption: (values, opts) => read("selectOption", (l) => l.selectOption(values, { timeout: opts?.timeout })),
540
+ setChecked: (checked, opts) => act("setChecked", { checked }, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
541
+ selectOption: (values, opts) => read("selectOption", (l) => l.selectOption(values, { timeout: opts?.timeout }), { values }),
497
542
  setInputFiles: (files, opts) => {
498
543
  const { arg, names } = lowerInputFiles(files);
499
544
  return act("setInputFiles", { files: names }, (l) => l.setInputFiles(arg, { timeout: opts?.timeout }));
@@ -502,7 +547,7 @@ export function makeLocator(backend, strategy, chain) {
502
547
  focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
503
548
  blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
504
549
  scrollIntoViewIfNeeded: (opts) => act("scrollIntoViewIfNeeded", {}, (l) => l.scrollIntoViewIfNeeded({ timeout: opts?.timeout })),
505
- dragTo: (target, opts) => act("dragTo", {}, (l, page) => l.dragTo(lower(page, chainOf(target)), { timeout: opts?.timeout })),
550
+ dragTo: (target, opts) => act("dragTo", { destinationTarget: recordedChain(chainOf(target)) }, (l, page) => l.dragTo(lower(page, chainOf(target)), { timeout: opts?.timeout })),
506
551
  // reads
507
552
  textContent: (opts) => read("textContent", (l) => l.textContent({ timeout: opts?.timeout })),
508
553
  innerText: (opts) => read("innerText", (l) => l.innerText({ timeout: opts?.timeout })),
@@ -530,8 +575,13 @@ export function makeLocator(backend, strategy, chain) {
530
575
  out.push(extend({ m: "nth", args: [i] }));
531
576
  return out;
532
577
  },
533
- evaluate: (description, fn, arg) => backend.pageOp("evaluate", { selector: label, description }, (page) => readable(page, () => lower(page, chain).evaluate(fn, arg)), { wrap: true }),
534
- waitFor: (opts) => act("waitFor", {}, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
578
+ evaluate: (description, fn, arg) => {
579
+ const script = truncateUtf8(fn.toString());
580
+ return read("evaluate", (l) => l.evaluate(fn, arg), {
581
+ description, script: script.value, scriptTruncated: script.truncated,
582
+ });
583
+ },
584
+ waitFor: (opts) => act("waitFor", { state: opts?.state ?? "visible" }, (l) => l.waitFor({ state: opts?.state, timeout: opts?.timeout })),
535
585
  };
536
586
  // Attach the silent-read probe under its symbol (kept off the typed literal
537
587
  // so declaration order stays simple).
@@ -399,9 +399,17 @@ export interface EnvEvent extends BaseEvent {
399
399
  error?: string;
400
400
  }
401
401
  export type BrowserAction = string;
402
+ /** JSON-safe locator syntax. Regexes use {regex, flags}, nested locators
403
+ * use another chain. Presentation is determined by the dashboard. */
404
+ export interface RecordedBrowserLocatorPart {
405
+ method: string;
406
+ args: unknown[];
407
+ }
402
408
  export interface BrowserEvent extends BaseEvent {
403
409
  kind: "browser";
404
410
  action: BrowserAction;
411
+ target?: RecordedBrowserLocatorPart[];
412
+ destinationTarget?: RecordedBrowserLocatorPart[];
405
413
  /** Target URL (navigate). */
406
414
  url?: string;
407
415
  /** CSS selector (click, scrollTo). */
@@ -421,6 +429,14 @@ export interface BrowserEvent extends BaseEvent {
421
429
  /** File names given to an `<input type="file">` (setInputFiles). Names
422
430
  * only — a built file's bytes stay out of the timeline. */
423
431
  files?: string[];
432
+ /** Arguments used by the dashboard to describe the operation completely. */
433
+ checked?: boolean;
434
+ values?: string | string[] | {
435
+ label?: string;
436
+ value?: string;
437
+ index?: number;
438
+ };
439
+ state?: string;
424
440
  /** Scroll/click coordinates. */
425
441
  dx?: number;
426
442
  dy?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.117.0",
3
+ "version": "0.119.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/browser.ts CHANGED
@@ -148,7 +148,7 @@ export interface BrowserSessionStep {
148
148
 
149
149
  // The set of actions that show up in the test event log. The session
150
150
  // step type extends this with "close" for the final drain.
151
- import type { BrowserAction } from "./recorder.js";
151
+ import type { BrowserAction, RecordedBrowserLocatorPart } from "./recorder.js";
152
152
  export type { BrowserAction } from "./recorder.js";
153
153
 
154
154
  /** Sink the daemon passes in to collect per-session rrweb event chunks. */
@@ -1949,7 +1949,7 @@ function buildBackend(
1949
1949
  insertText(text) {
1950
1950
  // insertText path (no per-char keydown) — the paste path.
1951
1951
  const t = truncateUtf8(text);
1952
- return instrumented("type", { text: t.value, textTruncated: t.truncated }, () =>
1952
+ return instrumented("insertText", { text: t.value, textTruncated: t.truncated }, () =>
1953
1953
  holder.page.keyboard.insertText(text),
1954
1954
  );
1955
1955
  },
@@ -2323,6 +2323,8 @@ function buildBackend(
2323
2323
  }
2324
2324
 
2325
2325
  export interface RecordableFields {
2326
+ target: RecordedBrowserLocatorPart[];
2327
+ destinationTarget: RecordedBrowserLocatorPart[];
2326
2328
  url: string;
2327
2329
  selector: string;
2328
2330
  description: string;
@@ -2341,4 +2343,7 @@ export interface RecordableFields {
2341
2343
  attribute: string;
2342
2344
  files: string[];
2343
2345
  targetNodeId: number;
2346
+ checked: boolean;
2347
+ values: string | string[] | { label?: string; value?: string; index?: number };
2348
+ state: string;
2344
2349
  }