@specific.dev/spectest 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/package.json +38 -0
- package/src/browser.ts +824 -0
- package/src/components/index.ts +32 -0
- package/src/components/k3s.ts +1324 -0
- package/src/components/postgres.ts +281 -0
- package/src/components/replayFake.ts +515 -0
- package/src/daemon.ts +3910 -0
- package/src/index.ts +1601 -0
- package/src/ingress.ts +288 -0
- package/src/inspect.ts +604 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +659 -0
- package/src/resolver.ts +351 -0
- package/src/terminal.ts +740 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +520 -0
- package/src/vendor/rrweb-record.min.js +5 -0
package/src/browser.ts
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
// Headless browser handle for tests. Thin wrapper around Bun.WebView
|
|
2
|
+
// driving Chromium-over-CDP. The wrapper keeps the public surface stable
|
|
3
|
+
// (we'd swap to a different backend without changing tests) and routes
|
|
4
|
+
// every call through the recorder so browser actions show up in a test's
|
|
5
|
+
// event log alongside exec/fetch/assertion events.
|
|
6
|
+
//
|
|
7
|
+
// On top of the per-op event recording, every Browser also captures an
|
|
8
|
+
// rrweb session: rrweb-record is injected into every document via CDP
|
|
9
|
+
// `Page.addScriptToEvaluateOnNewDocument`, the page buffers events on
|
|
10
|
+
// `window.__spectestRrwebEvents`, and we drain that buffer after every
|
|
11
|
+
// Browser op (and a final time on close). Drained chunks are tagged
|
|
12
|
+
// with the op that triggered the drain — that's the per-step structure
|
|
13
|
+
// the persistence layer stores so the dashboard can correlate replay
|
|
14
|
+
// timeline with the test's browser actions.
|
|
15
|
+
//
|
|
16
|
+
// Headless-Linux specifics live here: we force the chrome backend, add
|
|
17
|
+
// `--no-sandbox` (Chrome refuses to run as root otherwise) and
|
|
18
|
+
// `--disable-dev-shm-usage` (Firecracker's /dev/shm is tiny).
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
25
|
+
import { wrap } from "./inspect.js";
|
|
26
|
+
import type { Wrapped } from "./inspect.js";
|
|
27
|
+
|
|
28
|
+
// Minimal local declaration of the bits of Bun.WebView we use, so the SDK
|
|
29
|
+
// type-checks in projects that don't install `@types/bun` themselves. At
|
|
30
|
+
// runtime Bun supplies the real implementation.
|
|
31
|
+
interface BunWebViewBackendChrome {
|
|
32
|
+
type: "chrome";
|
|
33
|
+
path?: string;
|
|
34
|
+
argv?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BunWebViewOptions {
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
40
|
+
url?: string;
|
|
41
|
+
backend?: "chrome" | "webkit" | BunWebViewBackendChrome;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface BunWebViewScreenshotOptions {
|
|
45
|
+
encoding?: "buffer";
|
|
46
|
+
format?: ScreenshotFormat;
|
|
47
|
+
quality?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface BunWebViewInstance {
|
|
51
|
+
readonly url: string;
|
|
52
|
+
readonly title: string;
|
|
53
|
+
navigate(url: string): Promise<void>;
|
|
54
|
+
evaluate<T = unknown>(script: string): Promise<T>;
|
|
55
|
+
click(selector: string): Promise<void>;
|
|
56
|
+
click(x: number, y: number): Promise<void>;
|
|
57
|
+
type(text: string): Promise<void>;
|
|
58
|
+
press(key: string): Promise<void>;
|
|
59
|
+
scroll(dx: number, dy: number): Promise<void>;
|
|
60
|
+
scrollTo(selector: string): Promise<void>;
|
|
61
|
+
back(): Promise<void>;
|
|
62
|
+
forward(): Promise<void>;
|
|
63
|
+
reload(): Promise<void>;
|
|
64
|
+
screenshot(opts?: BunWebViewScreenshotOptions): Promise<Buffer>;
|
|
65
|
+
cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
66
|
+
close(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface BunWebViewCtor {
|
|
70
|
+
new (options?: BunWebViewOptions): BunWebViewInstance;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
declare const Bun: { WebView: BunWebViewCtor };
|
|
74
|
+
|
|
75
|
+
export interface BrowserOptions {
|
|
76
|
+
/** Viewport width in pixels. Default 1280. */
|
|
77
|
+
width?: number;
|
|
78
|
+
/** Viewport height in pixels. Default 720. */
|
|
79
|
+
height?: number;
|
|
80
|
+
/** Initial URL to navigate to before the constructor returns. */
|
|
81
|
+
url?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Sink that receives rrweb event chunks. Each Browser op (navigate,
|
|
84
|
+
* click, …) calls `recordStep` with the events that landed in
|
|
85
|
+
* `window.__spectestRrwebEvents` since the last drain. The daemon
|
|
86
|
+
* passes a per-test, per-session sink; if `null` (e.g. tests calling
|
|
87
|
+
* `openBrowser` directly without a test context), rrweb still
|
|
88
|
+
* records page-side but the buffer is discarded on close.
|
|
89
|
+
*/
|
|
90
|
+
recorder?: BrowserSessionRecorder | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type ScreenshotFormat = "png" | "jpeg" | "webp";
|
|
94
|
+
|
|
95
|
+
export interface ScreenshotOptions {
|
|
96
|
+
/** Image format. WebP requires Chrome. Default PNG. */
|
|
97
|
+
format?: ScreenshotFormat;
|
|
98
|
+
/** JPEG/WebP quality 0–100. Ignored for PNG. */
|
|
99
|
+
quality?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One Browser-action's worth of rrweb events, in the order rrweb emitted them. */
|
|
103
|
+
export interface BrowserSessionStep {
|
|
104
|
+
/** Monotonic counter within the session. */
|
|
105
|
+
stepSeq: number;
|
|
106
|
+
/** Browser action that triggered this drain — same set as in the
|
|
107
|
+
* per-op event log, plus `"close"` for the final pre-teardown drain. */
|
|
108
|
+
action: BrowserAction | "close";
|
|
109
|
+
/** Ms since the session was opened. */
|
|
110
|
+
tOffsetMs: number;
|
|
111
|
+
/** rrweb events as emitted by `rrweb.record`'s `emit` callback. */
|
|
112
|
+
events: unknown[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The set of actions that show up in the test event log. The session
|
|
116
|
+
// step type extends this with "close" for the final drain.
|
|
117
|
+
import type { BrowserAction } from "./recorder.js";
|
|
118
|
+
export type { BrowserAction } from "./recorder.js";
|
|
119
|
+
|
|
120
|
+
/** Sink the daemon passes in to collect per-session rrweb event chunks. */
|
|
121
|
+
export interface BrowserSessionRecorder {
|
|
122
|
+
/**
|
|
123
|
+
* Stable ID of this session — echoed into the per-op event log so
|
|
124
|
+
* the dashboard can link a browser event to its replay player.
|
|
125
|
+
*/
|
|
126
|
+
readonly sessionId: string;
|
|
127
|
+
/** Called for each drained chunk of rrweb events. */
|
|
128
|
+
recordStep(step: BrowserSessionStep): void;
|
|
129
|
+
/** Optional: called whenever `Browser.navigate(url)` is invoked. */
|
|
130
|
+
noteNavigation?(url: string): void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Headless browser handle. Operations are sequential per view — Bun rejects
|
|
135
|
+
* a second concurrent `evaluate()` with `ERR_INVALID_STATE`, and our wrapper
|
|
136
|
+
* inherits that. For parallel browsing open multiple views.
|
|
137
|
+
*/
|
|
138
|
+
export interface Browser {
|
|
139
|
+
/** Last-navigated URL (updated on navigate completion). */
|
|
140
|
+
readonly url: string;
|
|
141
|
+
/** Current page `<title>`. */
|
|
142
|
+
readonly title: string;
|
|
143
|
+
/** Navigate to a URL; resolves when the main frame's load completes. */
|
|
144
|
+
navigate(url: string): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Evaluate a JS expression in the page and return the
|
|
147
|
+
* JSON-deserialised result.
|
|
148
|
+
*
|
|
149
|
+
* `description` is a short human-readable label for what the
|
|
150
|
+
* snippet is doing ("read rendered todo list"); it surfaces in the
|
|
151
|
+
* test event log so the step list isn't a wall of minified code.
|
|
152
|
+
*/
|
|
153
|
+
evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
|
|
154
|
+
/**
|
|
155
|
+
* Poll `expression` in the page until it returns a truthy value
|
|
156
|
+
* (the returned value is what `waitFor` resolves with). Useful for
|
|
157
|
+
* UI assertions that need to wait for an async render — instead of
|
|
158
|
+
* a manual `while (Date.now() < deadline) await evaluate(...)` loop
|
|
159
|
+
* which clutters the test log with one event per poll, this records
|
|
160
|
+
* a single `waitFor` event with the total wait time and how many
|
|
161
|
+
* attempts it took.
|
|
162
|
+
*
|
|
163
|
+
* Express the predicate as "return the data if ready, else falsy":
|
|
164
|
+
*
|
|
165
|
+
* ```ts
|
|
166
|
+
* const items = await browser.waitFor<string[]>(
|
|
167
|
+
* "todo appears",
|
|
168
|
+
* "(() => { const xs = [...document.querySelectorAll('li')].map(l => l.textContent); return xs.includes('hi') ? xs : null; })()",
|
|
169
|
+
* );
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
172
|
+
* Defaults: 5 s total timeout, 100 ms between polls.
|
|
173
|
+
*/
|
|
174
|
+
waitFor<T = unknown>(
|
|
175
|
+
description: string,
|
|
176
|
+
expression: string,
|
|
177
|
+
options?: { timeoutMs?: number; intervalMs?: number },
|
|
178
|
+
): Promise<Wrapped<T>>;
|
|
179
|
+
/** Wait for `selector` to be actionable and click its center. */
|
|
180
|
+
click(selector: string): Promise<void>;
|
|
181
|
+
/** Click at the given viewport coordinates. */
|
|
182
|
+
clickAt(x: number, y: number): Promise<void>;
|
|
183
|
+
/** Insert text into the focused element (no `keydown` — same path as paste). */
|
|
184
|
+
type(text: string): Promise<void>;
|
|
185
|
+
/** Press a named key (`"Enter"`, `"Tab"`, …) or single character. */
|
|
186
|
+
press(key: string): Promise<void>;
|
|
187
|
+
/** Scroll the viewport by the given pixel delta. */
|
|
188
|
+
scroll(dx: number, dy: number): Promise<void>;
|
|
189
|
+
/** Wait for `selector` to exist and scroll it into view. */
|
|
190
|
+
scrollTo(selector: string): Promise<void>;
|
|
191
|
+
/** Navigate back in session history. */
|
|
192
|
+
back(): Promise<void>;
|
|
193
|
+
/** Navigate forward in session history. */
|
|
194
|
+
forward(): Promise<void>;
|
|
195
|
+
/** Reload the current page. */
|
|
196
|
+
reload(): Promise<void>;
|
|
197
|
+
/** Capture a PNG/JPEG/WebP screenshot of the viewport. Returns raw bytes. */
|
|
198
|
+
screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
|
|
199
|
+
/** Close the underlying view. Idempotent. Drains any pending rrweb events. */
|
|
200
|
+
close(): Promise<void>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Default extra flags for headless Chromium inside a Firecracker microVM.
|
|
204
|
+
// --no-sandbox: Chrome refuses to launch as root otherwise (no user
|
|
205
|
+
// namespaces in the guest).
|
|
206
|
+
// --disable-dev-shm-usage: /dev/shm in the microVM defaults to ~64MB,
|
|
207
|
+
// which Chromium will exhaust on non-trivial pages.
|
|
208
|
+
// --disable-features=AsyncDns,DnsOverHttps + --dns-over-https-mode=off:
|
|
209
|
+
// force Chromium onto glibc getaddrinfo for name resolution — the exact
|
|
210
|
+
// path `fetch()` uses (→ /etc/resolv.conf → 127.0.0.53 → spectest-resolver).
|
|
211
|
+
// Chromium's built-in async DNS client (and DoH auto-upgrade) bypass the
|
|
212
|
+
// loopback nameserver in /etc/resolv.conf and query public DNS directly,
|
|
213
|
+
// which has never heard of our service names (`dashboard`, `web`,
|
|
214
|
+
// `api.todos.local`, …). On a cold first navigate in a fresh fork that
|
|
215
|
+
// surfaces as an intermittent `net::ERR_NAME_NOT_RESOLVED`. getaddrinfo
|
|
216
|
+
// reads resolv.conf synchronously per lookup, so it has no startup race
|
|
217
|
+
// and always reaches the resolver. See run 878d0054 (dashboard:3000).
|
|
218
|
+
const CHROME_ARGV = [
|
|
219
|
+
"--no-sandbox",
|
|
220
|
+
"--disable-dev-shm-usage",
|
|
221
|
+
"--disable-features=AsyncDns,DnsOverHttps",
|
|
222
|
+
"--dns-over-https-mode=off",
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// rrweb bootstrap
|
|
227
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
// Vendored rrweb-record bundle (UMD, exposes `rrwebRecord` global). Read
|
|
230
|
+
// once at module init — the SDK ships this file in the base snapshot so
|
|
231
|
+
// no network fetch happens inside the VM.
|
|
232
|
+
const RRWEB_BUNDLE: string = (() => {
|
|
233
|
+
try {
|
|
234
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
235
|
+
const file = path.join(here, "vendor", "rrweb-record.min.js");
|
|
236
|
+
return readFileSync(file, "utf8");
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// If the vendored bundle is missing the SDK still works — the
|
|
239
|
+
// browser just records no rrweb events. Surface a warning so the
|
|
240
|
+
// mistake is visible.
|
|
241
|
+
// eslint-disable-next-line no-console
|
|
242
|
+
console.warn(
|
|
243
|
+
"[spectest] rrweb-record.min.js missing from SDK vendor dir; browser sessions will be empty",
|
|
244
|
+
err,
|
|
245
|
+
);
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
|
|
250
|
+
// Vendored console-record plugin bundle (UMD, exposes
|
|
251
|
+
// `rrwebPluginConsoleRecord` global). Captures the page's
|
|
252
|
+
// `console.{log,info,warn,error,debug,...}` calls and uncaught errors as
|
|
253
|
+
// rrweb Plugin events (`type: 6`, `data.plugin: "rrweb/console@1"`),
|
|
254
|
+
// which ride in the same `emit` stream as DOM mutations. Optional —
|
|
255
|
+
// missing bundle just means no console capture; the recorder still runs.
|
|
256
|
+
const RRWEB_CONSOLE_PLUGIN_BUNDLE: string = (() => {
|
|
257
|
+
try {
|
|
258
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
259
|
+
const file = path.join(here, "vendor", "rrweb-plugin-console-record.umd.js");
|
|
260
|
+
return readFileSync(file, "utf8");
|
|
261
|
+
} catch {
|
|
262
|
+
return "";
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
|
|
266
|
+
// Bootstrap that runs after the bundle defines `rrwebRecord`. Idempotent —
|
|
267
|
+
// the same script is added with `Page.addScriptToEvaluateOnNewDocument`
|
|
268
|
+
// and runs on every new document, so the `__spectestRrwebInit` guard keeps
|
|
269
|
+
// us from double-starting.
|
|
270
|
+
//
|
|
271
|
+
// `lengthThreshold` caps per-arg serialized size (default 1000 → bumped to
|
|
272
|
+
// 10 KiB so longer error stacks survive without dwarfing the event stream).
|
|
273
|
+
const RRWEB_BOOTSTRAP = `
|
|
274
|
+
;(function () {
|
|
275
|
+
if (typeof rrwebRecord !== "function") return;
|
|
276
|
+
if (window.__spectestRrwebInit) return;
|
|
277
|
+
window.__spectestRrwebInit = true;
|
|
278
|
+
window.__spectestRrwebEvents = [];
|
|
279
|
+
var plugins = [];
|
|
280
|
+
try {
|
|
281
|
+
if (typeof rrwebPluginConsoleRecord === "object" &&
|
|
282
|
+
typeof rrwebPluginConsoleRecord.getRecordConsolePlugin === "function") {
|
|
283
|
+
plugins.push(rrwebPluginConsoleRecord.getRecordConsolePlugin({
|
|
284
|
+
level: ["assert", "debug", "error", "info", "log", "trace", "warn"],
|
|
285
|
+
lengthThreshold: 10000,
|
|
286
|
+
logger: window.console,
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
} catch (e) { /* plugin init failed — keep recording DOM only. */ }
|
|
290
|
+
try {
|
|
291
|
+
rrwebRecord({
|
|
292
|
+
emit: function (ev) { window.__spectestRrwebEvents.push(ev); },
|
|
293
|
+
plugins: plugins,
|
|
294
|
+
// Capture page assets into the event stream so the replay renders
|
|
295
|
+
// faithfully offline (the dashboard reconstructs the DOM with no
|
|
296
|
+
// access to the env's origin). \`inlineStylesheet\` (default, set
|
|
297
|
+
// explicitly) bakes <style>/<link> CSS into the snapshot;
|
|
298
|
+
// \`inlineImages\` turns <img> and CSS background images into data
|
|
299
|
+
// URIs. \`collectFonts\` only captures fonts added via the JS
|
|
300
|
+
// FontFace API — it does NOT inline static \`@font-face { src:url() }\`
|
|
301
|
+
// CSS (what next/font emits), so it's a no-op for most apps; the
|
|
302
|
+
// url()-based fonts are handled by the inliner below instead. All
|
|
303
|
+
// degrade gracefully — an asset that can't be read is skipped,
|
|
304
|
+
// never fatal to recording.
|
|
305
|
+
inlineStylesheet: true,
|
|
306
|
+
collectFonts: true,
|
|
307
|
+
inlineImages: true,
|
|
308
|
+
});
|
|
309
|
+
} catch (e) {
|
|
310
|
+
/* DOM not yet ready or rrweb mis-init — skip. */
|
|
311
|
+
}
|
|
312
|
+
// ── Inline @font-face fonts as base64 ──────────────────────────────
|
|
313
|
+
// The replay is viewed OUTSIDE this hermetic VM, so a @font-face whose
|
|
314
|
+
// src points at a same-origin URL (e.g. next/font's
|
|
315
|
+
// /_next/static/media/*.woff2) is unreachable at replay time and the
|
|
316
|
+
// page silently falls back to a system font. collectFonts above won't
|
|
317
|
+
// help (FontFace-API fonts only), so inline the bytes ourselves: once
|
|
318
|
+
// fonts have loaded, fetch each url() (same-origin -> allowed, and the
|
|
319
|
+
// page already trusts the in-VM CA), rewrite the rule's src to a data:
|
|
320
|
+
// URI, then take a fresh full snapshot so the now-self-contained CSS
|
|
321
|
+
// is captured. Plain string ops, no regex — this whole script ships
|
|
322
|
+
// inside a TS template literal where regex backslashes get mangled.
|
|
323
|
+
(function () {
|
|
324
|
+
if (window.__spectestFontsInlined) return;
|
|
325
|
+
window.__spectestFontsInlined = true;
|
|
326
|
+
// Diagnostics ride the console-record plugin, so they land in the
|
|
327
|
+
// recording (and state.db) — our only debugging window into the
|
|
328
|
+
// headless page. Prefix is grep-able; keep them terse.
|
|
329
|
+
function log(m) { try { console.log("[spectest-fonts] " + m); } catch (e) {} }
|
|
330
|
+
var cache = {};
|
|
331
|
+
function fetchDataUri(url) {
|
|
332
|
+
if (cache[url]) return cache[url];
|
|
333
|
+
cache[url] = fetch(url).then(function (r) {
|
|
334
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
335
|
+
return r.blob();
|
|
336
|
+
}).then(function (blob) {
|
|
337
|
+
return new Promise(function (resolve, reject) {
|
|
338
|
+
var fr = new FileReader();
|
|
339
|
+
fr.onload = function () { resolve(fr.result); };
|
|
340
|
+
fr.onerror = reject;
|
|
341
|
+
fr.readAsDataURL(blob);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
return cache[url];
|
|
345
|
+
}
|
|
346
|
+
// Extract every url() token in cssText, resolved against baseHref.
|
|
347
|
+
// CSSOM cssText keeps urls as authored — next/font emits
|
|
348
|
+
// absolute-path url(/_next/static/media/x.woff2) (no scheme), so a
|
|
349
|
+
// bare http prefix check misses them (that's why run b04cab1f / 6987
|
|
350
|
+
// saw withHttpUrl=0). Resolve against the sheet href (or the document
|
|
351
|
+
// base for inline style) before fetching. token is the raw string as
|
|
352
|
+
// it appears in cssText, used for the later string replace.
|
|
353
|
+
function urlsIn(text, baseHref) {
|
|
354
|
+
var out = [], i = 0;
|
|
355
|
+
while (true) {
|
|
356
|
+
var u = text.indexOf("url(", i);
|
|
357
|
+
if (u < 0) break;
|
|
358
|
+
var start = u + 4, end = text.indexOf(")", start);
|
|
359
|
+
if (end < 0) break;
|
|
360
|
+
var raw = text.slice(start, end).trim();
|
|
361
|
+
if (raw && (raw.charAt(0) === '"' || raw.charAt(0) === "'")) {
|
|
362
|
+
raw = raw.slice(1, raw.length - 1);
|
|
363
|
+
}
|
|
364
|
+
i = end + 1;
|
|
365
|
+
if (!raw || raw.lastIndexOf("data:", 0) === 0) continue; // already inline
|
|
366
|
+
var abs;
|
|
367
|
+
try { abs = new URL(raw, baseHref).href; } catch (e) { continue; }
|
|
368
|
+
if (abs.lastIndexOf("http", 0) !== 0) continue; // only fetchable http(s)
|
|
369
|
+
out.push({ token: raw, abs: abs });
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
function eachFontFace(rules, fn) {
|
|
374
|
+
for (var i = 0; i < rules.length; i++) {
|
|
375
|
+
var rule = rules[i];
|
|
376
|
+
if (rule.type === 5) fn(rule); // CSSRule.FONT_FACE_RULE
|
|
377
|
+
else if (rule.cssRules) { // @media / @supports nesting
|
|
378
|
+
try { eachFontFace(rule.cssRules, fn); } catch (e) {}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function run() {
|
|
383
|
+
// Collect each @font-face's *cssText* (reliable for any CSSRule —
|
|
384
|
+
// unlike .style.setProperty('src',…), which silently no-ops on
|
|
385
|
+
// CSSFontFaceRule in Chrome) plus the http url()s inside it.
|
|
386
|
+
var faces = [], sheets = document.styleSheets, readable = 0, faceCount = 0;
|
|
387
|
+
for (var s = 0; s < sheets.length; s++) {
|
|
388
|
+
var rules;
|
|
389
|
+
try { rules = sheets[s].cssRules; } catch (e) { continue; } // cross-origin
|
|
390
|
+
if (!rules) continue;
|
|
391
|
+
readable++;
|
|
392
|
+
var base = sheets[s].href || document.baseURI;
|
|
393
|
+
eachFontFace(rules, function (rule) {
|
|
394
|
+
faceCount++;
|
|
395
|
+
var cssText = rule.cssText || "";
|
|
396
|
+
var urls = urlsIn(cssText, base);
|
|
397
|
+
if (urls.length) faces.push({ cssText: cssText, urls: urls });
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
log("sheets=" + sheets.length + " readable=" + readable +
|
|
401
|
+
" fontFaces=" + faceCount + " withUrl=" + faces.length);
|
|
402
|
+
if (!faces.length) return;
|
|
403
|
+
var jobs = faces.map(function (face) {
|
|
404
|
+
return Promise.all(face.urls.map(function (u) {
|
|
405
|
+
return fetchDataUri(u.abs)
|
|
406
|
+
.then(function (d) { return { token: u.token, dataUri: d }; })
|
|
407
|
+
.catch(function (e) { log("fetch FAIL " + u.abs + " : " + (e && e.message)); return null; });
|
|
408
|
+
})).then(function (pairs) {
|
|
409
|
+
var text = face.cssText, changed = false;
|
|
410
|
+
for (var k = 0; k < pairs.length; k++) {
|
|
411
|
+
if (!pairs[k]) continue;
|
|
412
|
+
text = text.split(pairs[k].token).join(pairs[k].dataUri);
|
|
413
|
+
changed = true;
|
|
414
|
+
}
|
|
415
|
+
return changed ? text : null;
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
Promise.all(jobs).then(function (texts) {
|
|
419
|
+
var ok = texts.filter(function (t) { return t; });
|
|
420
|
+
if (!ok.length) { log("nothing inlined (all fetches failed?)"); return; }
|
|
421
|
+
// Inject the rewritten @font-face rules as a NEW <style> appended
|
|
422
|
+
// last. A later @font-face with identical descriptors wins, and a
|
|
423
|
+
// freshly-added node is captured reliably by rrweb's mutation
|
|
424
|
+
// observer (no dependence on CSSOM edits being serialized).
|
|
425
|
+
try {
|
|
426
|
+
var st = document.createElement("style");
|
|
427
|
+
st.setAttribute("data-spectest-inlined-fonts", "1");
|
|
428
|
+
st.appendChild(document.createTextNode(ok.join("\\n")));
|
|
429
|
+
(document.head || document.documentElement).appendChild(st);
|
|
430
|
+
log("injected " + ok.length + " @font-face rule(s) as data: URIs");
|
|
431
|
+
} catch (e) { log("inject FAIL " + (e && e.message)); return; }
|
|
432
|
+
try {
|
|
433
|
+
if (typeof rrwebRecord === "function" &&
|
|
434
|
+
typeof rrwebRecord.takeFullSnapshot === "function") {
|
|
435
|
+
rrwebRecord.takeFullSnapshot();
|
|
436
|
+
log("took full snapshot");
|
|
437
|
+
}
|
|
438
|
+
} catch (e) { log("snapshot FAIL " + (e && e.message)); }
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
|
|
443
|
+
document.fonts.ready.then(function () { setTimeout(run, 0); });
|
|
444
|
+
} else if (document.readyState === "complete") {
|
|
445
|
+
run();
|
|
446
|
+
} else {
|
|
447
|
+
window.addEventListener("load", run);
|
|
448
|
+
}
|
|
449
|
+
} catch (e) { log("init FAIL " + (e && e.message)); }
|
|
450
|
+
})();
|
|
451
|
+
})();
|
|
452
|
+
`;
|
|
453
|
+
|
|
454
|
+
const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
|
|
455
|
+
? `${RRWEB_BUNDLE}\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n${RRWEB_BOOTSTRAP}`
|
|
456
|
+
: "";
|
|
457
|
+
|
|
458
|
+
// Page-side expression that atomically swaps in a fresh buffer and
|
|
459
|
+
// returns the old one. Wrapped as a single expression so view.evaluate's
|
|
460
|
+
// `await (...)` wrapper accepts it.
|
|
461
|
+
//
|
|
462
|
+
// `forceFullIfMissing` (inlined by `drainExpr`) guards a recovery path
|
|
463
|
+
// for the most common replay failure: a post-navigation page whose full
|
|
464
|
+
// snapshot never made it into the stream. rrweb defers its *initial*
|
|
465
|
+
// full snapshot to the page's `load` event, but our drains fire at op
|
|
466
|
+
// boundaries — `waitFor` in particular returns the instant its predicate
|
|
467
|
+
// is truthy, which is usually right after `DOMContentLoaded`, *before*
|
|
468
|
+
// `load`. Login/redirect chains compound this: every new document resets
|
|
469
|
+
// `window.__spectestRrwebEvents`, so a partial chunk from an intermediate
|
|
470
|
+
// page is discarded. The net effect is a final step that carries only a
|
|
471
|
+
// `DOMContentLoaded` (type 0) and no Meta/FullSnapshot — the player then
|
|
472
|
+
// has no DOM for the new page and keeps showing the previous one (e.g.
|
|
473
|
+
// the auth provider's login screen instead of the post-login dashboard).
|
|
474
|
+
//
|
|
475
|
+
// When the caller detects the document changed since the last drain
|
|
476
|
+
// (`view.url` differs) it passes `force=true`; if the outgoing buffer
|
|
477
|
+
// then lacks any FullSnapshot (type 2), we call `rrwebRecord.takeFullSnapshot()`
|
|
478
|
+
// to synthesize one for the *current* DOM. That emits a fresh Meta
|
|
479
|
+
// (with the correct href) + FullSnapshot, so the step is self-contained
|
|
480
|
+
// and seeking to it shows the right page. Gating on url-change + missing
|
|
481
|
+
// snapshot keeps steady-state steps (clicks/types on an unchanged page,
|
|
482
|
+
// or navigations where `load` already fired) from bloating the stream
|
|
483
|
+
// with redundant snapshots.
|
|
484
|
+
function drainExpr(forceFullIfMissing: boolean): string {
|
|
485
|
+
return `(function () {
|
|
486
|
+
try {
|
|
487
|
+
if (${forceFullIfMissing ? "true" : "false"} &&
|
|
488
|
+
window.__spectestRrwebInit &&
|
|
489
|
+
typeof rrwebRecord === "function" &&
|
|
490
|
+
typeof rrwebRecord.takeFullSnapshot === "function") {
|
|
491
|
+
var b = window.__spectestRrwebEvents || [];
|
|
492
|
+
var hasFull = false;
|
|
493
|
+
for (var i = 0; i < b.length; i++) {
|
|
494
|
+
if (b[i] && b[i].type === 2) { hasFull = true; break; }
|
|
495
|
+
}
|
|
496
|
+
if (!hasFull) rrwebRecord.takeFullSnapshot();
|
|
497
|
+
}
|
|
498
|
+
} catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
|
|
499
|
+
var buf = window.__spectestRrwebEvents;
|
|
500
|
+
if (!buf || !buf.length) return [];
|
|
501
|
+
window.__spectestRrwebEvents = [];
|
|
502
|
+
return buf;
|
|
503
|
+
})()`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
507
|
+
// Factory
|
|
508
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
/** A pre-opened, never-used view: renderer already spawned, rrweb init
|
|
511
|
+
* script installed, parked on about:blank. */
|
|
512
|
+
interface PooledView {
|
|
513
|
+
view: BunWebViewInstance;
|
|
514
|
+
recordingInstalled: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Pre-opened view pool. Renderer spawn is the expensive part of
|
|
518
|
+
// `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
|
|
519
|
+
// (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
|
|
520
|
+
// lived through a snapshot restore, i.e. in every test fork. The daemon
|
|
521
|
+
// fills this pool once at the end of /bootstrap; the warm-template and
|
|
522
|
+
// pretest snapshots are captured after that, so EVERY fork inherits a
|
|
523
|
+
// live renderer and the first `ctx.browser()` of a test skips the spawn
|
|
524
|
+
// entirely. Lives in daemon memory → forks each get the pristine pool,
|
|
525
|
+
// and a test consuming it never affects its siblings (same isolation as
|
|
526
|
+
// fake state).
|
|
527
|
+
const VIEW_POOL: PooledView[] = [];
|
|
528
|
+
|
|
529
|
+
/** Create a view + CDP session + rrweb init script — the slow part. */
|
|
530
|
+
async function createView(width: number, height: number): Promise<PooledView> {
|
|
531
|
+
const view = new Bun.WebView({
|
|
532
|
+
width,
|
|
533
|
+
height,
|
|
534
|
+
backend: { type: "chrome", argv: CHROME_ARGV },
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// Bun's docs: `cdp()` requires at least one navigate first to set up
|
|
538
|
+
// the CDP session. about:blank is the cheapest bootstrap target.
|
|
539
|
+
await view.navigate("about:blank");
|
|
540
|
+
|
|
541
|
+
let recordingInstalled = false;
|
|
542
|
+
if (PAGE_INIT_SCRIPT) {
|
|
543
|
+
try {
|
|
544
|
+
await view.cdp("Page.addScriptToEvaluateOnNewDocument", {
|
|
545
|
+
source: PAGE_INIT_SCRIPT,
|
|
546
|
+
});
|
|
547
|
+
recordingInstalled = true;
|
|
548
|
+
} catch (err) {
|
|
549
|
+
// Without rrweb the browser still works; just no replay.
|
|
550
|
+
// eslint-disable-next-line no-console
|
|
551
|
+
console.warn("[spectest] failed to install rrweb recorder:", err);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return { view, recordingInstalled };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
559
|
+
* /bootstrap, before the warm-template snapshot is captured). Only
|
|
560
|
+
* default-viewport views are pooled — `openBrowser` with a custom
|
|
561
|
+
* width/height bypasses the pool.
|
|
562
|
+
*/
|
|
563
|
+
export async function prewarmViewPool(n = 1): Promise<void> {
|
|
564
|
+
for (let i = 0; i < n; i++) {
|
|
565
|
+
VIEW_POOL.push(await createView(1280, 720));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Open a browser view. Always uses the Chrome backend in the daemon
|
|
571
|
+
* (Firecracker guest is Linux; WKWebView isn't available). Serves from
|
|
572
|
+
* the pre-opened pool when the caller uses the default viewport.
|
|
573
|
+
*/
|
|
574
|
+
export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
|
|
575
|
+
const wantW = opts.width ?? 1280;
|
|
576
|
+
const wantH = opts.height ?? 720;
|
|
577
|
+
const pooled =
|
|
578
|
+
wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
|
|
579
|
+
const { view, recordingInstalled } = pooled ?? (await createView(wantW, wantH));
|
|
580
|
+
|
|
581
|
+
const browser = wrapView(view, opts.recorder ?? null, recordingInstalled);
|
|
582
|
+
|
|
583
|
+
// We deliberately don't forward `opts.url` to the constructor — going
|
|
584
|
+
// through our own `navigate()` keeps the recorder log uniform (one
|
|
585
|
+
// event per navigation, with timing) and drains rrweb after the load.
|
|
586
|
+
if (opts.url !== undefined) {
|
|
587
|
+
await browser.navigate(opts.url);
|
|
588
|
+
}
|
|
589
|
+
return browser;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function wrapView(
|
|
593
|
+
view: BunWebViewInstance,
|
|
594
|
+
recorder: BrowserSessionRecorder | null,
|
|
595
|
+
recordingInstalled: boolean,
|
|
596
|
+
): Browser {
|
|
597
|
+
let closed = false;
|
|
598
|
+
const sessionStart = Date.now();
|
|
599
|
+
let stepSeq = 0;
|
|
600
|
+
// URL observed at the previous drain. A change means the main frame
|
|
601
|
+
// navigated to a new document since we last looked, which is exactly
|
|
602
|
+
// when rrweb's load-deferred full snapshot is most likely to be
|
|
603
|
+
// missing from the chunk we're about to drain (see `drainExpr`).
|
|
604
|
+
let lastDrainUrl: string | null = null;
|
|
605
|
+
|
|
606
|
+
async function drain(action: BrowserAction | "close"): Promise<void> {
|
|
607
|
+
if (!recordingInstalled || !recorder || closed) return;
|
|
608
|
+
try {
|
|
609
|
+
const urlChanged = view.url !== lastDrainUrl;
|
|
610
|
+
const events = await view.evaluate<unknown[]>(drainExpr(urlChanged));
|
|
611
|
+
lastDrainUrl = view.url;
|
|
612
|
+
if (Array.isArray(events) && events.length > 0) {
|
|
613
|
+
recorder.recordStep({
|
|
614
|
+
stepSeq: stepSeq++,
|
|
615
|
+
action,
|
|
616
|
+
tOffsetMs: Date.now() - sessionStart,
|
|
617
|
+
events,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
} catch {
|
|
621
|
+
// Page may be mid-navigation, detached, or the view is closing.
|
|
622
|
+
// Losing an occasional drain is acceptable — the next op picks up
|
|
623
|
+
// the rest of the buffer.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function instrumented<T>(
|
|
628
|
+
action: BrowserAction,
|
|
629
|
+
fields: Partial<RecordableFields>,
|
|
630
|
+
fn: () => Promise<T>,
|
|
631
|
+
): Promise<T> {
|
|
632
|
+
const t = Date.now();
|
|
633
|
+
const resv = reserveEvent();
|
|
634
|
+
try {
|
|
635
|
+
const result = await fn();
|
|
636
|
+
// `sessionTimestamp` is the post-op wall clock — that's where we
|
|
637
|
+
// want the dashboard's seek to land, so clicking "type 'foo'"
|
|
638
|
+
// shows the input *with* the text, not the empty field just
|
|
639
|
+
// before the keystrokes. The dashboard converts to a player
|
|
640
|
+
// offset by subtracting `events[0].timestamp`.
|
|
641
|
+
const endT = Date.now();
|
|
642
|
+
const seq = recordBrowser({
|
|
643
|
+
action,
|
|
644
|
+
...fields,
|
|
645
|
+
...(recorder
|
|
646
|
+
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
647
|
+
: {}),
|
|
648
|
+
durationMs: endT - t,
|
|
649
|
+
}, resv);
|
|
650
|
+
await drain(action);
|
|
651
|
+
// Only `evaluate` and `waitFor` return user-visible JS values
|
|
652
|
+
// that someone is likely to assert on. Other actions return void
|
|
653
|
+
// or browser internals; leave them raw to avoid Proxy surprises.
|
|
654
|
+
if (seq !== undefined && (action === "evaluate" || action === "waitFor")) {
|
|
655
|
+
return wrap(result, seq) as T;
|
|
656
|
+
}
|
|
657
|
+
return result;
|
|
658
|
+
} catch (err) {
|
|
659
|
+
const e = err as Error;
|
|
660
|
+
const endT = Date.now();
|
|
661
|
+
recordBrowser({
|
|
662
|
+
action,
|
|
663
|
+
...fields,
|
|
664
|
+
...(recorder
|
|
665
|
+
? { sessionId: recorder.sessionId, sessionTimestamp: endT }
|
|
666
|
+
: {}),
|
|
667
|
+
durationMs: endT - t,
|
|
668
|
+
error: e?.message ?? String(err),
|
|
669
|
+
}, resv);
|
|
670
|
+
// Still try to drain — the failure itself may have produced
|
|
671
|
+
// useful rrweb events (mutations from a half-loaded page, etc.).
|
|
672
|
+
await drain(action);
|
|
673
|
+
throw err;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
get url() {
|
|
679
|
+
return view.url;
|
|
680
|
+
},
|
|
681
|
+
get title() {
|
|
682
|
+
return view.title;
|
|
683
|
+
},
|
|
684
|
+
navigate(url) {
|
|
685
|
+
recorder?.noteNavigation?.(url);
|
|
686
|
+
return instrumented("navigate", { url }, () => view.navigate(url));
|
|
687
|
+
},
|
|
688
|
+
async evaluate<T = unknown>(
|
|
689
|
+
description: string,
|
|
690
|
+
script: string,
|
|
691
|
+
): Promise<Wrapped<T>> {
|
|
692
|
+
const truncatedScript = truncateUtf8(script);
|
|
693
|
+
// `instrumented` wraps the result for evaluate/waitFor (see line ~622),
|
|
694
|
+
// so the value is a `Wrapped<T>` at runtime; the cast matches the type.
|
|
695
|
+
return instrumented<T>(
|
|
696
|
+
"evaluate",
|
|
697
|
+
{
|
|
698
|
+
description,
|
|
699
|
+
script: truncatedScript.value,
|
|
700
|
+
scriptTruncated: truncatedScript.truncated,
|
|
701
|
+
},
|
|
702
|
+
async () => {
|
|
703
|
+
const v = await view.evaluate<T>(script);
|
|
704
|
+
return v;
|
|
705
|
+
},
|
|
706
|
+
) as Promise<Wrapped<T>>;
|
|
707
|
+
},
|
|
708
|
+
async waitFor<T = unknown>(
|
|
709
|
+
description: string,
|
|
710
|
+
expression: string,
|
|
711
|
+
options: { timeoutMs?: number; intervalMs?: number } = {},
|
|
712
|
+
): Promise<Wrapped<T>> {
|
|
713
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
714
|
+
const intervalMs = options.intervalMs ?? 100;
|
|
715
|
+
const truncatedScript = truncateUtf8(expression);
|
|
716
|
+
// Pass `fields` by reference so the loop can stamp the final
|
|
717
|
+
// attempt count onto the event before instrumented records it.
|
|
718
|
+
const fields: Partial<RecordableFields> = {
|
|
719
|
+
description,
|
|
720
|
+
script: truncatedScript.value,
|
|
721
|
+
scriptTruncated: truncatedScript.truncated,
|
|
722
|
+
attempts: 0,
|
|
723
|
+
};
|
|
724
|
+
return instrumented<T>("waitFor", fields, async () => {
|
|
725
|
+
const deadline = Date.now() + timeoutMs;
|
|
726
|
+
// (return value wrapped by `instrumented`; cast below matches.)
|
|
727
|
+
// The polling loop calls `view.evaluate` directly (not the
|
|
728
|
+
// wrapped `evaluate`) so it doesn't fan out into N events in
|
|
729
|
+
// the log or N rrweb drains. rrweb keeps buffering page-side
|
|
730
|
+
// throughout the wait; the wrapper's single drain at the end
|
|
731
|
+
// collects everything.
|
|
732
|
+
for (;;) {
|
|
733
|
+
fields.attempts = (fields.attempts ?? 0) + 1;
|
|
734
|
+
let v: unknown;
|
|
735
|
+
try {
|
|
736
|
+
v = await view.evaluate<unknown>(expression);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
if (Date.now() >= deadline) throw err;
|
|
739
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (v) return v as T;
|
|
743
|
+
if (Date.now() >= deadline) {
|
|
744
|
+
throw new Error(
|
|
745
|
+
`waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${fields.attempts} attempts)`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
749
|
+
}
|
|
750
|
+
}) as Promise<Wrapped<T>>;
|
|
751
|
+
},
|
|
752
|
+
click(selector) {
|
|
753
|
+
return instrumented("click", { selector }, () => view.click(selector));
|
|
754
|
+
},
|
|
755
|
+
clickAt(x, y) {
|
|
756
|
+
return instrumented("click", { x, y }, () => view.click(x, y));
|
|
757
|
+
},
|
|
758
|
+
type(text) {
|
|
759
|
+
const t = truncateUtf8(text);
|
|
760
|
+
return instrumented(
|
|
761
|
+
"type",
|
|
762
|
+
{ text: t.value, textTruncated: t.truncated },
|
|
763
|
+
() => view.type(text),
|
|
764
|
+
);
|
|
765
|
+
},
|
|
766
|
+
press(key) {
|
|
767
|
+
return instrumented("press", { key }, () => view.press(key));
|
|
768
|
+
},
|
|
769
|
+
scroll(dx, dy) {
|
|
770
|
+
return instrumented("scroll", { dx, dy }, () => view.scroll(dx, dy));
|
|
771
|
+
},
|
|
772
|
+
scrollTo(selector) {
|
|
773
|
+
return instrumented("scrollTo", { selector }, () => view.scrollTo(selector));
|
|
774
|
+
},
|
|
775
|
+
back() {
|
|
776
|
+
return instrumented("back", {}, () => view.back());
|
|
777
|
+
},
|
|
778
|
+
forward() {
|
|
779
|
+
return instrumented("forward", {}, () => view.forward());
|
|
780
|
+
},
|
|
781
|
+
reload() {
|
|
782
|
+
return instrumented("reload", {}, () => view.reload());
|
|
783
|
+
},
|
|
784
|
+
async screenshot(options) {
|
|
785
|
+
const format = options?.format ?? "png";
|
|
786
|
+
return instrumented("screenshot", { format }, async () => {
|
|
787
|
+
const buf = await view.screenshot({
|
|
788
|
+
encoding: "buffer",
|
|
789
|
+
format,
|
|
790
|
+
quality: options?.quality,
|
|
791
|
+
});
|
|
792
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
793
|
+
});
|
|
794
|
+
},
|
|
795
|
+
async close() {
|
|
796
|
+
if (closed) return;
|
|
797
|
+
// Final drain before the page evaporates.
|
|
798
|
+
await drain("close");
|
|
799
|
+
closed = true;
|
|
800
|
+
try {
|
|
801
|
+
view.close();
|
|
802
|
+
} catch {
|
|
803
|
+
/* already closed by the runtime */
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
interface RecordableFields {
|
|
810
|
+
url: string;
|
|
811
|
+
selector: string;
|
|
812
|
+
description: string;
|
|
813
|
+
script: string;
|
|
814
|
+
scriptTruncated: boolean;
|
|
815
|
+
text: string;
|
|
816
|
+
textTruncated: boolean;
|
|
817
|
+
key: string;
|
|
818
|
+
dx: number;
|
|
819
|
+
dy: number;
|
|
820
|
+
x: number;
|
|
821
|
+
y: number;
|
|
822
|
+
format: ScreenshotFormat;
|
|
823
|
+
attempts: number;
|
|
824
|
+
}
|