@specific.dev/spectest 0.118.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 +10 -1
- package/dist/browser.js +1 -1
- package/dist/harness/network.d.ts +18 -0
- package/dist/harness/network.js +194 -0
- package/dist/locator.js +64 -14
- package/dist/recorder.d.ts +16 -0
- package/package.json +1 -1
- package/src/browser.ts +7 -2
- package/src/harness/network.test.ts +265 -0
- package/src/harness/network.ts +178 -0
- package/src/locator-recording.test.ts +150 -0
- package/src/locator.ts +69 -28
- package/src/recorder.ts +13 -0
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("
|
|
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
|
|
331
|
-
*
|
|
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.
|
|
350
|
-
|
|
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
|
-
}
|
|
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) =>
|
|
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 = {}) =>
|
|
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) =>
|
|
534
|
-
|
|
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).
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
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("
|
|
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
|
}
|