@ralphralphai/playwright 0.2.0 → 1.2.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/LICENSE +201 -0
- package/README.md +126 -147
- package/dist/index.cjs +23 -584
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +93 -45
- package/dist/index.mjs +16 -554
- package/dist/index.mjs.map +1 -1
- package/dist/reporter.cjs +185 -0
- package/dist/reporter.cjs.map +1 -0
- package/dist/reporter.d.cts +1 -0
- package/dist/reporter.d.mts +53 -0
- package/dist/reporter.mjs +183 -0
- package/dist/reporter.mjs.map +1 -0
- package/dist/upload-DPYMUP9Y.mjs +702 -0
- package/dist/upload-DPYMUP9Y.mjs.map +1 -0
- package/dist/upload-DxF1r2db.cjs +816 -0
- package/dist/upload-DxF1r2db.cjs.map +1 -0
- package/package.json +26 -16
package/dist/index.cjs
CHANGED
|
@@ -1,580 +1,8 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
|
|
3
|
-
var __create = Object.create;
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
-
var __copyProps = (to, from, except, desc) => {
|
|
10
|
-
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
-
key = keys[i];
|
|
12
|
-
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
-
get: ((k) => from[k]).bind(null, key),
|
|
14
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
return to;
|
|
18
|
-
};
|
|
19
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
20
|
-
value: mod,
|
|
21
|
-
enumerable: true
|
|
22
|
-
}) : target, mod));
|
|
23
|
-
//#endregion
|
|
2
|
+
const require_upload = require("./upload-DxF1r2db.cjs");
|
|
24
3
|
let node_path = require("node:path");
|
|
25
|
-
node_path = __toESM(node_path, 1);
|
|
4
|
+
node_path = require_upload.__toESM(node_path, 1);
|
|
26
5
|
let _playwright_test = require("@playwright/test");
|
|
27
|
-
let node_fs = require("node:fs");
|
|
28
|
-
let node_fs_promises = require("node:fs/promises");
|
|
29
|
-
let _ralphralphai_config_package_json = require("@ralphralphai/config/package.json");
|
|
30
|
-
_ralphralphai_config_package_json = __toESM(_ralphralphai_config_package_json, 1);
|
|
31
|
-
let _ralphralphai_config_zod = require("@ralphralphai/config/zod");
|
|
32
|
-
let jsonc_parser = require("jsonc-parser");
|
|
33
|
-
let node_crypto = require("node:crypto");
|
|
34
|
-
let node_timers_promises = require("node:timers/promises");
|
|
35
|
-
let _playwright_test_package_json = require("@playwright/test/package.json");
|
|
36
|
-
_playwright_test_package_json = __toESM(_playwright_test_package_json, 1);
|
|
37
|
-
let node_util = require("node:util");
|
|
38
|
-
let node_zlib = require("node:zlib");
|
|
39
|
-
let tar_stream = require("tar-stream");
|
|
40
|
-
//#region src/config.ts
|
|
41
|
-
function resolveMode(options) {
|
|
42
|
-
const mode = options.mode ?? process.env["RALPH_MODE"] ?? (process.env["RALPH_UPLOAD_KEY"] ? "upload" : "off");
|
|
43
|
-
if (mode === "off" || mode === "local" || mode === "upload") return mode;
|
|
44
|
-
throw new Error("RALPH_MODE must be off, local, or upload.");
|
|
45
|
-
}
|
|
46
|
-
async function loadConfig(options, root) {
|
|
47
|
-
const file = configFile(options, root);
|
|
48
|
-
return snapshot(options.config ?? parseConfig(await (0, node_fs_promises.readFile)(file, "utf8")));
|
|
49
|
-
}
|
|
50
|
-
function loadConfigSync(options, root) {
|
|
51
|
-
const file = configFile(options, root);
|
|
52
|
-
return snapshot(options.config ?? parseConfig((0, node_fs.readFileSync)(file, "utf8")));
|
|
53
|
-
}
|
|
54
|
-
function configFile(options, root) {
|
|
55
|
-
if (options.config && options.configPath) throw new Error("Provide either ralphOptions.config or configPath, not both.");
|
|
56
|
-
return node_path.default.resolve(root, options.configPath ?? "ralph.jsonc");
|
|
57
|
-
}
|
|
58
|
-
function parseConfig(text) {
|
|
59
|
-
const errors = [];
|
|
60
|
-
const value = (0, jsonc_parser.parse)(text, errors, { allowTrailingComma: true });
|
|
61
|
-
if (errors.length) throw new Error("Invalid Ralph config: " + errors.map((error) => (0, jsonc_parser.printParseErrorCode)(error.error) + " at " + error.offset).join(", "));
|
|
62
|
-
return value;
|
|
63
|
-
}
|
|
64
|
-
function snapshot(value) {
|
|
65
|
-
_ralphralphai_config_zod.WebRalphConfigSchema.parse(value);
|
|
66
|
-
return {
|
|
67
|
-
config: JSON.parse(JSON.stringify(value)),
|
|
68
|
-
packageVersion: _ralphralphai_config_package_json.default.version
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
//#endregion
|
|
72
|
-
//#region package.json
|
|
73
|
-
var version = "0.2.0";
|
|
74
|
-
//#endregion
|
|
75
|
-
//#region src/image.ts
|
|
76
|
-
/**
|
|
77
|
-
* Matches the crawler's full-page screenshots: lossless measured larger than this
|
|
78
|
-
* on real sites, which are mostly photographic content.
|
|
79
|
-
*/
|
|
80
|
-
const WEBP_QUALITY = 80;
|
|
81
|
-
async function takeScreenshot(page, deadline) {
|
|
82
|
-
const options = {
|
|
83
|
-
fullPage: true,
|
|
84
|
-
scale: "css"
|
|
85
|
-
};
|
|
86
|
-
const webp = await page.screenshot({
|
|
87
|
-
...options,
|
|
88
|
-
type: "webp",
|
|
89
|
-
quality: WEBP_QUALITY,
|
|
90
|
-
timeout: Math.max(1, deadline - Date.now())
|
|
91
|
-
});
|
|
92
|
-
if (webp.length > 0) return {
|
|
93
|
-
bytes: webp,
|
|
94
|
-
contentType: "image/webp",
|
|
95
|
-
...webpSize(webp)
|
|
96
|
-
};
|
|
97
|
-
const png = await page.screenshot({
|
|
98
|
-
...options,
|
|
99
|
-
type: "png",
|
|
100
|
-
timeout: Math.max(1, deadline - Date.now())
|
|
101
|
-
});
|
|
102
|
-
return {
|
|
103
|
-
bytes: png,
|
|
104
|
-
contentType: "image/png",
|
|
105
|
-
width: png.readUInt32BE(16),
|
|
106
|
-
height: png.readUInt32BE(20)
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
function imageExtension(contentType) {
|
|
110
|
-
return contentType === "image/png" ? ".png" : ".webp";
|
|
111
|
-
}
|
|
112
|
-
function webpSize(bytes) {
|
|
113
|
-
if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") throw new Error("The screenshot is not a WebP image.");
|
|
114
|
-
const chunk = bytes.toString("ascii", 12, 16);
|
|
115
|
-
if (chunk === "VP8X") return {
|
|
116
|
-
width: 1 + bytes.readUIntLE(24, 3),
|
|
117
|
-
height: 1 + bytes.readUIntLE(27, 3)
|
|
118
|
-
};
|
|
119
|
-
if (chunk === "VP8 ") return {
|
|
120
|
-
width: bytes.readUInt16LE(26) & 16383,
|
|
121
|
-
height: bytes.readUInt16LE(28) & 16383
|
|
122
|
-
};
|
|
123
|
-
if (chunk === "VP8L") {
|
|
124
|
-
const bits = bytes.readUInt32LE(21);
|
|
125
|
-
return {
|
|
126
|
-
width: 1 + (bits & 16383),
|
|
127
|
-
height: 1 + (bits >> 14 & 16383)
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
throw new Error("Unsupported WebP chunk: " + chunk);
|
|
131
|
-
}
|
|
132
|
-
//#endregion
|
|
133
|
-
//#region src/layout.ts
|
|
134
|
-
function readLayout(page) {
|
|
135
|
-
return page.locator("[data-track-id]").filter({ visible: true }).evaluateAll((elements) => ({
|
|
136
|
-
viewport: {
|
|
137
|
-
width: window.innerWidth,
|
|
138
|
-
height: window.innerHeight
|
|
139
|
-
},
|
|
140
|
-
document: {
|
|
141
|
-
width: Math.max(document.documentElement.scrollWidth, document.documentElement.clientWidth, document.body?.scrollWidth ?? 0),
|
|
142
|
-
height: Math.max(document.documentElement.scrollHeight, document.documentElement.clientHeight, document.body?.scrollHeight ?? 0)
|
|
143
|
-
},
|
|
144
|
-
scroll: {
|
|
145
|
-
x: window.scrollX,
|
|
146
|
-
y: window.scrollY
|
|
147
|
-
},
|
|
148
|
-
devicePixelRatio: window.devicePixelRatio,
|
|
149
|
-
trackedElements: elements.flatMap((element) => {
|
|
150
|
-
const trackId = element.getAttribute("data-track-id");
|
|
151
|
-
const rect = element.getBoundingClientRect();
|
|
152
|
-
if (!trackId || rect.width <= 0 || rect.height <= 0 || rect.right <= 0 || rect.left >= window.innerWidth) return [];
|
|
153
|
-
return [{
|
|
154
|
-
trackId,
|
|
155
|
-
x: rect.x + window.scrollX,
|
|
156
|
-
y: rect.y + window.scrollY,
|
|
157
|
-
width: rect.width,
|
|
158
|
-
height: rect.height
|
|
159
|
-
}];
|
|
160
|
-
})
|
|
161
|
-
}));
|
|
162
|
-
}
|
|
163
|
-
//#endregion
|
|
164
|
-
//#region src/upload.ts
|
|
165
|
-
function resolveUploadOptions(options) {
|
|
166
|
-
const uploadKey = process.env["RALPH_UPLOAD_KEY"];
|
|
167
|
-
const apiUrl = options.apiUrl ?? process.env["RALPH_API_URL"];
|
|
168
|
-
const appId = options.appId ?? process.env["RALPH_APP_ID"];
|
|
169
|
-
if (!uploadKey || !apiUrl || !appId) throw new Error("Ralph upload requires RALPH_UPLOAD_KEY, RALPH_API_URL (or ralphOptions.apiUrl), and RALPH_APP_ID (or ralphOptions.appId).");
|
|
170
|
-
return {
|
|
171
|
-
uploadKey,
|
|
172
|
-
apiUrl,
|
|
173
|
-
appId,
|
|
174
|
-
timeoutMs: options.uploadTimeoutMs
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
const RAW_GRAPH_BUNDLE_INDEX = "raw_graph.json";
|
|
178
|
-
const RAW_GRAPH_BUNDLE_IMAGE_DIR = "assets/images";
|
|
179
|
-
/**
|
|
180
|
-
* Packs the manifest and screenshots into a gzipped tar archive:
|
|
181
|
-
*
|
|
182
|
-
* raw_graph.json { manifest, screenshots: [{ nodeId, path }] }
|
|
183
|
-
* assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
|
|
184
|
-
*/
|
|
185
|
-
async function createRawGraphBundle(manifest, screenshots) {
|
|
186
|
-
if (Buffer.byteLength(JSON.stringify(manifest)) > 2097152 || screenshots.length > 500 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 67108864) throw new Error("Ralph raw graph exceeds upload limits.");
|
|
187
|
-
const images = screenshots.map(({ nodeId, contentType, bytes }) => {
|
|
188
|
-
if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
|
|
189
|
-
return {
|
|
190
|
-
nodeId,
|
|
191
|
-
path: `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`,
|
|
192
|
-
bytes
|
|
193
|
-
};
|
|
194
|
-
});
|
|
195
|
-
const index = {
|
|
196
|
-
manifest,
|
|
197
|
-
screenshots: images.map(({ nodeId, path }) => ({
|
|
198
|
-
nodeId,
|
|
199
|
-
path
|
|
200
|
-
}))
|
|
201
|
-
};
|
|
202
|
-
const attrs = {
|
|
203
|
-
mtime: /* @__PURE__ */ new Date(0),
|
|
204
|
-
uid: 0,
|
|
205
|
-
gid: 0
|
|
206
|
-
};
|
|
207
|
-
const tar = (0, tar_stream.pack)();
|
|
208
|
-
tar.entry({
|
|
209
|
-
name: RAW_GRAPH_BUNDLE_INDEX,
|
|
210
|
-
...attrs
|
|
211
|
-
}, JSON.stringify(index));
|
|
212
|
-
for (const { path, bytes } of images) tar.entry({
|
|
213
|
-
name: path,
|
|
214
|
-
...attrs
|
|
215
|
-
}, Buffer.from(bytes));
|
|
216
|
-
tar.finalize();
|
|
217
|
-
const chunks = [];
|
|
218
|
-
for await (const chunk of tar) chunks.push(chunk);
|
|
219
|
-
const compressed = await (0, node_util.promisify)(node_zlib.gzip)(Buffer.concat(chunks));
|
|
220
|
-
if (compressed.byteLength > 67108864) throw new Error("Ralph compressed raw graph bundle exceeds upload limits.");
|
|
221
|
-
return compressed;
|
|
222
|
-
}
|
|
223
|
-
function isLoopback(url) {
|
|
224
|
-
return url.hostname === "localhost" || url.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(url.hostname);
|
|
225
|
-
}
|
|
226
|
-
async function uploadRawGraph(manifest, screenshots, options) {
|
|
227
|
-
if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) throw new Error(`Ralph app id is not valid: ${options.appId}`);
|
|
228
|
-
const url = new URL(`/api/upload/playwright/apps/${options.appId}/attempts`, options.apiUrl);
|
|
229
|
-
if (!(url.protocol === "https:" || url.protocol === "http:" && isLoopback(url)) || !options.uploadKey) throw new Error("Ralph upload requires an HTTPS API URL (HTTP only for loopback hosts) and an upload key.");
|
|
230
|
-
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
231
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Ralph uploadTimeoutMs must be positive and finite.");
|
|
232
|
-
const blob = new Blob([new Uint8Array(await createRawGraphBundle(manifest, screenshots))], { type: "application/gzip" });
|
|
233
|
-
const response = await fetch(url, {
|
|
234
|
-
method: "POST",
|
|
235
|
-
headers: { Authorization: `Bearer ${options.uploadKey}` },
|
|
236
|
-
body: blob,
|
|
237
|
-
redirect: "error",
|
|
238
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
239
|
-
});
|
|
240
|
-
if (!response.ok) throw new Error(`Ralph upload failed (HTTP ${response.status}). Local Ralph artifacts are retained for retry.`);
|
|
241
|
-
const receipt = await response.json();
|
|
242
|
-
if (receipt.result !== "ok" || receipt.status !== "received" || receipt.attemptId !== manifest.attemptId || typeof receipt.resultId !== "string" || typeof receipt.replayed !== "boolean") throw new Error("Ralph server returned an invalid upload receipt.");
|
|
243
|
-
return receipt;
|
|
244
|
-
}
|
|
245
|
-
//#endregion
|
|
246
|
-
//#region src/recorder.ts
|
|
247
|
-
const MANIFEST_ATTACHMENT = "ralph-raw-graph";
|
|
248
|
-
const DEFAULT_SETTLE_MS = 150;
|
|
249
|
-
const DEFAULT_RECORD_TIMEOUT_MS = 5e3;
|
|
250
|
-
/** Where the raw graph and screenshots are written, inside Playwright's per-test output directory. */
|
|
251
|
-
const OUTPUT_DIR = "ralph";
|
|
252
|
-
var Recorder = class {
|
|
253
|
-
options;
|
|
254
|
-
config;
|
|
255
|
-
testInfo;
|
|
256
|
-
upload;
|
|
257
|
-
/**
|
|
258
|
-
* Identifies this run of the test. A retry gets a new recorder, and so a new
|
|
259
|
-
* id, which is what lets the server tell retries apart and dedupe re-uploads.
|
|
260
|
-
*/
|
|
261
|
-
attemptId = (0, node_crypto.randomUUID)();
|
|
262
|
-
startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
263
|
-
/** How long to wait after a navigation's DOM loads before recording. */
|
|
264
|
-
settleMs;
|
|
265
|
-
/**
|
|
266
|
-
* The budget for recording one node, from settling through the screenshot.
|
|
267
|
-
* Past it, the node is recorded as failed.
|
|
268
|
-
*/
|
|
269
|
-
recordTimeoutMs;
|
|
270
|
-
/**
|
|
271
|
-
* Every context being watched. `onPage` is kept so `stopContext` can remove
|
|
272
|
-
* the exact listener `observe` added. The id is stored in the manifest's
|
|
273
|
-
* `pages[].contextId`.
|
|
274
|
-
*/
|
|
275
|
-
contexts = /* @__PURE__ */ new Map();
|
|
276
|
-
/**
|
|
277
|
-
* Every tab seen in a watched context, including closed ones, so their
|
|
278
|
-
* nodes and opener links still reach the manifest.
|
|
279
|
-
*/
|
|
280
|
-
pages = /* @__PURE__ */ new Map();
|
|
281
|
-
/**
|
|
282
|
-
* Each node's outcome, in completion order. Nodes in one tab complete
|
|
283
|
-
* in sequence, but tabs interleave; `finish` sorts them.
|
|
284
|
-
*/
|
|
285
|
-
nodes = [];
|
|
286
|
-
/**
|
|
287
|
-
* When each node was requested, keyed by node id, for linking a
|
|
288
|
-
* popup's first node to its opener's latest. Numbers, so they compare
|
|
289
|
-
* without parsing each node's `requestedAt` string.
|
|
290
|
-
*/
|
|
291
|
-
requestedAtMs = /* @__PURE__ */ new Map();
|
|
292
|
-
/** Set by `finish`, after which `observe` throws. */
|
|
293
|
-
closed = false;
|
|
294
|
-
constructor(options, config, testInfo, upload) {
|
|
295
|
-
this.options = options;
|
|
296
|
-
this.config = config;
|
|
297
|
-
this.testInfo = testInfo;
|
|
298
|
-
this.upload = upload;
|
|
299
|
-
this.settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
|
|
300
|
-
this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_RECORD_TIMEOUT_MS;
|
|
301
|
-
if (!(Number.isFinite(this.settleMs) && this.settleMs >= 0 && Number.isFinite(this.recordTimeoutMs) && this.recordTimeoutMs > this.settleMs)) throw new Error("Ralph recordTimeoutMs must be finite and greater than settleMs, which must be non-negative.");
|
|
302
|
-
}
|
|
303
|
-
/** Starts recording URL changes in every current and future tab of `context`. */
|
|
304
|
-
observe(context) {
|
|
305
|
-
if (this.closed) throw new Error("The Ralph recorder has finished.");
|
|
306
|
-
if (this.contexts.has(context)) return;
|
|
307
|
-
const id = "context-" + this.contexts.size;
|
|
308
|
-
const onPage = (page) => this.watchPage(page, id);
|
|
309
|
-
this.contexts.set(context, {
|
|
310
|
-
id,
|
|
311
|
-
onPage
|
|
312
|
-
});
|
|
313
|
-
context.on("page", onPage);
|
|
314
|
-
for (const page of context.pages()) onPage(page);
|
|
315
|
-
}
|
|
316
|
-
/** Records the page as it is now, e.g. an open dialog at the same URL. */
|
|
317
|
-
async recordNode(page, options = {}) {
|
|
318
|
-
this.observe(page.context());
|
|
319
|
-
const state = this.pages.get(page);
|
|
320
|
-
if (!state) throw new Error("Cannot record a closed browser page.");
|
|
321
|
-
await this.enqueue(state, "explicit", options);
|
|
322
|
-
}
|
|
323
|
-
/** Waits for every queued recording to finish. */
|
|
324
|
-
async flush() {
|
|
325
|
-
await Promise.all([...this.pages.values()].map((state) => state.tail));
|
|
326
|
-
}
|
|
327
|
-
/** Stops watching `context` and waits for its queued recordings. */
|
|
328
|
-
async stopContext(context) {
|
|
329
|
-
const entry = this.contexts.get(context);
|
|
330
|
-
if (entry) context.off("page", entry.onPage);
|
|
331
|
-
const states = [...this.pages.values()].filter((state) => state.page.context() === context);
|
|
332
|
-
for (const state of states) state.page.off("framenavigated", state.onNavigation);
|
|
333
|
-
await Promise.all(states.map((state) => state.tail));
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Stops recording, then writes the manifest, uploads it if configured, and
|
|
337
|
-
* fails a passing test whose nodes failed to record.
|
|
338
|
-
*/
|
|
339
|
-
async finish() {
|
|
340
|
-
this.closed = true;
|
|
341
|
-
await Promise.all([...this.contexts.keys()].map((context) => this.stopContext(context)));
|
|
342
|
-
await Promise.all([...this.pages.values()].map((state) => state.opener));
|
|
343
|
-
const nodes = this.nodes.sort((a, b) => a.pageId.localeCompare(b.pageId) || a.sequence - b.sequence);
|
|
344
|
-
linkPreviousNodes(nodes, (pageId) => this.pageInfo(pageId)?.openerPageId, (nodeId) => this.requestedAtMs.get(nodeId) ?? 0);
|
|
345
|
-
const failures = nodes.filter((item) => item.status === "failed");
|
|
346
|
-
const manifest = this.buildManifest(nodes, failures.length);
|
|
347
|
-
await this.writeManifest(manifest);
|
|
348
|
-
if (this.upload) await this.uploadManifest(manifest, this.upload);
|
|
349
|
-
if (failures.length && this.testInfo.status === "passed") throw new Error("Ralph could not record " + failures.length + " page state(s). See the ralph-raw-graph attachment.");
|
|
350
|
-
}
|
|
351
|
-
watchPage(page, contextId) {
|
|
352
|
-
if (this.pages.has(page)) return;
|
|
353
|
-
const state = {
|
|
354
|
-
page,
|
|
355
|
-
info: {
|
|
356
|
-
pageId: "page-" + this.pages.size,
|
|
357
|
-
contextId,
|
|
358
|
-
browserName: page.context().browser()?.browserType().name()
|
|
359
|
-
},
|
|
360
|
-
lastUrl: "",
|
|
361
|
-
generation: 0,
|
|
362
|
-
sequence: 0,
|
|
363
|
-
tail: Promise.resolve(),
|
|
364
|
-
opener: Promise.resolve(),
|
|
365
|
-
onNavigation: (frame) => {
|
|
366
|
-
if (frame === page.mainFrame()) this.urlChanged(state);
|
|
367
|
-
}
|
|
368
|
-
};
|
|
369
|
-
this.pages.set(page, state);
|
|
370
|
-
state.opener = page.opener().then((opener) => {
|
|
371
|
-
if (opener) state.info.openerPageId = this.pages.get(opener)?.info.pageId;
|
|
372
|
-
}).catch(() => void 0);
|
|
373
|
-
page.on("framenavigated", state.onNavigation);
|
|
374
|
-
this.urlChanged(state);
|
|
375
|
-
}
|
|
376
|
-
urlChanged(state) {
|
|
377
|
-
state.generation++;
|
|
378
|
-
const url = state.page.url();
|
|
379
|
-
if (url === state.lastUrl) return;
|
|
380
|
-
state.lastUrl = url;
|
|
381
|
-
if (isHttpUrl(url)) this.enqueue(state, "url-change", {}).catch(() => void 0);
|
|
382
|
-
}
|
|
383
|
-
/** Queues a recording behind the tab's earlier ones. */
|
|
384
|
-
enqueue(state, trigger, options) {
|
|
385
|
-
const actualUrl = state.page.url();
|
|
386
|
-
const url = options.url === void 0 ? actualUrl : new URL(options.url, actualUrl).href;
|
|
387
|
-
if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) throw new Error("Ralph recordings require HTTP or HTTPS page and recorded URLs.");
|
|
388
|
-
const request = {
|
|
389
|
-
nodeId: (0, node_crypto.randomUUID)(),
|
|
390
|
-
pageId: state.info.pageId,
|
|
391
|
-
sequence: state.sequence++,
|
|
392
|
-
requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
393
|
-
actualUrl,
|
|
394
|
-
url,
|
|
395
|
-
trigger,
|
|
396
|
-
...options.state === void 0 ? {} : { state: options.state }
|
|
397
|
-
};
|
|
398
|
-
this.requestedAtMs.set(request.nodeId, Date.now());
|
|
399
|
-
const generation = state.generation;
|
|
400
|
-
const task = state.tail.then(() => this.record(state, generation, request));
|
|
401
|
-
state.tail = task.catch(() => void 0);
|
|
402
|
-
return task;
|
|
403
|
-
}
|
|
404
|
-
/**
|
|
405
|
-
* Records one node and its outcome. Fails if the tab navigates or
|
|
406
|
-
* closes before it finishes, or if it runs past the record timeout.
|
|
407
|
-
*/
|
|
408
|
-
async record(state, generation, request) {
|
|
409
|
-
const { page } = state;
|
|
410
|
-
const deadline = startDeadline(this.recordTimeoutMs, "Ralph recording timed out.");
|
|
411
|
-
const { wait } = deadline;
|
|
412
|
-
const navigatedAway = () => state.generation !== generation || page.url() !== request.actualUrl;
|
|
413
|
-
const check = () => {
|
|
414
|
-
deadline.signal.throwIfAborted();
|
|
415
|
-
if (navigatedAway()) throw new Error("The page navigated before its recording completed.");
|
|
416
|
-
if (page.isClosed()) throw new Error("The page closed before its recording completed.");
|
|
417
|
-
};
|
|
418
|
-
try {
|
|
419
|
-
check();
|
|
420
|
-
if (request.trigger === "url-change") await this.waitForPageToSettle(page, deadline, check);
|
|
421
|
-
check();
|
|
422
|
-
const before = await wait(readLayout(page));
|
|
423
|
-
check();
|
|
424
|
-
const image = await wait(takeScreenshot(page, deadline.endsAt));
|
|
425
|
-
check();
|
|
426
|
-
const after = await wait(readLayout(page));
|
|
427
|
-
check();
|
|
428
|
-
this.nodes.push({
|
|
429
|
-
...request,
|
|
430
|
-
status: "recorded",
|
|
431
|
-
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
432
|
-
layout: before,
|
|
433
|
-
geometryStable: JSON.stringify(before) === JSON.stringify(after),
|
|
434
|
-
screenshot: await this.saveScreenshot(request.nodeId, image)
|
|
435
|
-
});
|
|
436
|
-
} catch (error) {
|
|
437
|
-
this.nodes.push({
|
|
438
|
-
...request,
|
|
439
|
-
status: navigatedAway() ? "superseded" : "failed",
|
|
440
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
441
|
-
});
|
|
442
|
-
if (request.trigger === "explicit") throw error;
|
|
443
|
-
} finally {
|
|
444
|
-
deadline.cancel();
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
/** After a navigation: the DOM, the settle delay, then the `ready` hook. */
|
|
448
|
-
async waitForPageToSettle(page, { wait, signal }, check) {
|
|
449
|
-
await wait(page.waitForLoadState("domcontentloaded", { timeout: this.recordTimeoutMs }));
|
|
450
|
-
await wait((0, node_timers_promises.setTimeout)(this.settleMs, void 0, { signal }));
|
|
451
|
-
check();
|
|
452
|
-
if (this.options.ready) await wait(this.options.ready(page, signal));
|
|
453
|
-
}
|
|
454
|
-
/** Writes the image to the test output and attaches it to the report. */
|
|
455
|
-
async saveScreenshot(nodeId, image) {
|
|
456
|
-
const path = "ralph/" + nodeId + imageExtension(image.contentType);
|
|
457
|
-
const attachmentName = "ralph-screenshot-" + nodeId;
|
|
458
|
-
await (0, node_fs_promises.mkdir)(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
|
|
459
|
-
await (0, node_fs_promises.writeFile)(this.testInfo.outputPath(path), image.bytes);
|
|
460
|
-
await this.testInfo.attach(attachmentName, {
|
|
461
|
-
path: this.testInfo.outputPath(path),
|
|
462
|
-
contentType: image.contentType
|
|
463
|
-
});
|
|
464
|
-
return {
|
|
465
|
-
path,
|
|
466
|
-
attachmentName,
|
|
467
|
-
width: image.width,
|
|
468
|
-
height: image.height,
|
|
469
|
-
scale: "css",
|
|
470
|
-
contentType: image.contentType
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
pageInfo(pageId) {
|
|
474
|
-
return [...this.pages.values()].find((state) => state.info.pageId === pageId)?.info;
|
|
475
|
-
}
|
|
476
|
-
buildManifest(nodes, failureCount) {
|
|
477
|
-
const { testInfo } = this;
|
|
478
|
-
const testPassed = testInfo.status === "passed";
|
|
479
|
-
return {
|
|
480
|
-
formatVersion: 1,
|
|
481
|
-
producer: {
|
|
482
|
-
name: "@ralphralphai/playwright",
|
|
483
|
-
version,
|
|
484
|
-
playwrightVersion: _playwright_test_package_json.default.version
|
|
485
|
-
},
|
|
486
|
-
runId: this.options.runId ?? process.env["RALPH_RUN_ID"],
|
|
487
|
-
buildId: this.options.buildId ?? process.env["RALPH_BUILD_ID"],
|
|
488
|
-
attemptId: this.attemptId,
|
|
489
|
-
test: {
|
|
490
|
-
id: testInfo.testId,
|
|
491
|
-
titlePath: testInfo.titlePath,
|
|
492
|
-
project: testInfo.project.name,
|
|
493
|
-
retry: testInfo.retry,
|
|
494
|
-
repeatEachIndex: testInfo.repeatEachIndex,
|
|
495
|
-
workerIndex: testInfo.workerIndex,
|
|
496
|
-
parallelIndex: testInfo.parallelIndex,
|
|
497
|
-
shard: testInfo.config.shard,
|
|
498
|
-
status: failureCount && testPassed ? "failed" : testInfo.status ?? "unknown",
|
|
499
|
-
expectedStatus: testInfo.expectedStatus
|
|
500
|
-
},
|
|
501
|
-
startedAt: this.startedAt,
|
|
502
|
-
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
503
|
-
config: this.config,
|
|
504
|
-
pages: [...this.pages.values()].map((state) => state.info),
|
|
505
|
-
nodes,
|
|
506
|
-
complete: testPassed && nodes.length > 0 && nodes.every((item) => item.status === "recorded" && item.geometryStable)
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
async writeManifest(manifest) {
|
|
510
|
-
const file = this.testInfo.outputPath(OUTPUT_DIR, "raw_graph.json");
|
|
511
|
-
await (0, node_fs_promises.mkdir)(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
|
|
512
|
-
await (0, node_fs_promises.writeFile)(file, JSON.stringify(manifest, null, 2) + "\n");
|
|
513
|
-
await this.testInfo.attach(MANIFEST_ATTACHMENT, {
|
|
514
|
-
path: file,
|
|
515
|
-
contentType: "application/json"
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
async uploadManifest(manifest, upload) {
|
|
519
|
-
const recorded = manifest.nodes.filter((item) => item.status === "recorded");
|
|
520
|
-
const screenshots = [];
|
|
521
|
-
for (const { nodeId, screenshot } of recorded) screenshots.push({
|
|
522
|
-
nodeId,
|
|
523
|
-
contentType: screenshot.contentType,
|
|
524
|
-
bytes: await (0, node_fs_promises.readFile)(this.testInfo.outputPath(screenshot.path))
|
|
525
|
-
});
|
|
526
|
-
const receipt = await uploadRawGraph(manifest, screenshots, upload);
|
|
527
|
-
await this.testInfo.attach("ralph-upload-receipt", {
|
|
528
|
-
body: JSON.stringify(receipt),
|
|
529
|
-
contentType: "application/json"
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
function isHttpUrl(url) {
|
|
534
|
-
return /^https?:/.test(url);
|
|
535
|
-
}
|
|
536
|
-
function startDeadline(timeoutMs, message) {
|
|
537
|
-
const controller = new AbortController();
|
|
538
|
-
const { signal } = controller;
|
|
539
|
-
const timer = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
|
|
540
|
-
const aborted = new Promise((_, reject) => {
|
|
541
|
-
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
542
|
-
});
|
|
543
|
-
aborted.catch(() => void 0);
|
|
544
|
-
return {
|
|
545
|
-
signal,
|
|
546
|
-
endsAt: Date.now() + timeoutMs,
|
|
547
|
-
wait: (promise) => Promise.race([promise, aborted]),
|
|
548
|
-
cancel: () => clearTimeout(timer)
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
/**
|
|
552
|
-
* Sets `previousNodeId` on each node: the one before it in the same
|
|
553
|
-
* tab, or for a popup's first node, its opener's latest. Superseded nodes
|
|
554
|
-
* are skipped, since their page state was never shown.
|
|
555
|
-
*
|
|
556
|
-
* `nodes` must be sorted by page, then sequence.
|
|
557
|
-
*/
|
|
558
|
-
function linkPreviousNodes(nodes, openerOf, requestedAtMs) {
|
|
559
|
-
const byPage = /* @__PURE__ */ new Map();
|
|
560
|
-
for (const item of nodes) byPage.set(item.pageId, [...byPage.get(item.pageId) ?? [], item]);
|
|
561
|
-
const openerNodeBefore = (item) => {
|
|
562
|
-
const openerPageId = openerOf(item.pageId);
|
|
563
|
-
const requestedAt = requestedAtMs(item.nodeId);
|
|
564
|
-
return byPage.get(openerPageId ?? "")?.filter((candidate) => requestedAtMs(candidate.nodeId) <= requestedAt).at(-1);
|
|
565
|
-
};
|
|
566
|
-
const previous = /* @__PURE__ */ new Map();
|
|
567
|
-
for (const items of byPage.values()) items.forEach((item, index) => {
|
|
568
|
-
const from = index > 0 ? items[index - 1] : openerNodeBefore(item);
|
|
569
|
-
if (from) previous.set(item, from);
|
|
570
|
-
});
|
|
571
|
-
for (const item of nodes) {
|
|
572
|
-
let from = previous.get(item);
|
|
573
|
-
while (from?.status === "superseded") from = previous.get(from);
|
|
574
|
-
if (from) item.previousNodeId = from.nodeId;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
//#endregion
|
|
578
6
|
//#region src/screens.ts
|
|
579
7
|
const SCREEN_TAG_PREFIX = "@ralph-screen:";
|
|
580
8
|
function screenLabel(size) {
|
|
@@ -585,7 +13,7 @@ function screenLabel(size) {
|
|
|
585
13
|
* Each project's viewport is that screen size.
|
|
586
14
|
*/
|
|
587
15
|
function ralphProjects(base = {}, options = {}) {
|
|
588
|
-
const { screenSizes } = loadConfigSync(options, options.root ?? process.cwd()).config;
|
|
16
|
+
const { screenSizes } = require_upload.loadConfigSync(options, options.root ?? process.cwd()).config;
|
|
589
17
|
if (!screenSizes.length) throw new Error("The Ralph config declares no screenSizes.");
|
|
590
18
|
return screenSizes.map((screenSize) => {
|
|
591
19
|
const label = screenLabel(screenSize);
|
|
@@ -636,11 +64,13 @@ const test = _playwright_test.test.extend({
|
|
|
636
64
|
await use();
|
|
637
65
|
}, { auto: true }],
|
|
638
66
|
_ralphRecorder: [async ({ ralphOptions, _ralphConfigs, _ralphScreen }, use, testInfo) => {
|
|
639
|
-
|
|
67
|
+
const mode = require_upload.resolveMode(ralphOptions);
|
|
68
|
+
if (!testInfo.tags.includes("@ralph") || mode === "off") {
|
|
640
69
|
await use(null);
|
|
641
70
|
return;
|
|
642
71
|
}
|
|
643
|
-
const
|
|
72
|
+
const reported = process.env["RALPH_PLAYWRIGHT_REPORTER"] || testInfo.config.reporter.some(([name]) => name === "blob");
|
|
73
|
+
if (mode === "upload" && !reported) throw new Error("Ralph uploads one merged graph per run from its reporter. Add ['@ralphralphai/playwright/reporter'] to `reporter` in playwright.config, or use the blob reporter and merge-reports when sharding.");
|
|
644
74
|
const root = testInfo.config.configFile ? node_path.default.dirname(testInfo.config.configFile) : process.cwd();
|
|
645
75
|
const key = JSON.stringify({
|
|
646
76
|
root,
|
|
@@ -649,10 +79,10 @@ const test = _playwright_test.test.extend({
|
|
|
649
79
|
});
|
|
650
80
|
let pending = _ralphConfigs.get(key);
|
|
651
81
|
if (!pending) {
|
|
652
|
-
pending = loadConfig(ralphOptions, root);
|
|
82
|
+
pending = require_upload.loadConfig(ralphOptions, root);
|
|
653
83
|
_ralphConfigs.set(key, pending);
|
|
654
84
|
}
|
|
655
|
-
const recorder = new Recorder(ralphOptions, await pending, testInfo,
|
|
85
|
+
const recorder = new require_upload.Recorder(ralphOptions, await pending, testInfo, mode);
|
|
656
86
|
try {
|
|
657
87
|
await use(recorder);
|
|
658
88
|
} finally {
|
|
@@ -664,18 +94,26 @@ const test = _playwright_test.test.extend({
|
|
|
664
94
|
}],
|
|
665
95
|
ralph: async ({ _ralphRecorder, ralphOptions }, use) => {
|
|
666
96
|
const checkOptIn = () => {
|
|
667
|
-
if (resolveMode(ralphOptions) !== "off") throw new Error("Opt this test into Ralph with the @ralph tag.");
|
|
97
|
+
if (require_upload.resolveMode(ralphOptions) !== "off") throw new Error("Opt this test into Ralph with the @ralph tag.");
|
|
668
98
|
};
|
|
669
99
|
await use(_ralphRecorder ?? {
|
|
670
100
|
recordNode: async () => {
|
|
671
101
|
checkOptIn();
|
|
672
102
|
},
|
|
673
|
-
|
|
103
|
+
waitForCapture: async () => {},
|
|
104
|
+
pause: () => {},
|
|
105
|
+
resume: () => {},
|
|
674
106
|
observe: () => {
|
|
675
107
|
checkOptIn();
|
|
676
108
|
}
|
|
677
109
|
});
|
|
678
110
|
},
|
|
111
|
+
contextOptions: async ({ contextOptions, ralphOptions }, use, testInfo) => {
|
|
112
|
+
await use(testInfo.tags.includes("@ralph") && require_upload.resolveMode(ralphOptions) !== "off" && ralphOptions.reduceMotion ? {
|
|
113
|
+
...contextOptions,
|
|
114
|
+
reducedMotion: "reduce"
|
|
115
|
+
} : contextOptions);
|
|
116
|
+
},
|
|
679
117
|
context: async ({ context, _ralphRecorder }, use) => {
|
|
680
118
|
_ralphRecorder?.observe(context);
|
|
681
119
|
try {
|
|
@@ -686,9 +124,9 @@ const test = _playwright_test.test.extend({
|
|
|
686
124
|
}
|
|
687
125
|
});
|
|
688
126
|
//#endregion
|
|
689
|
-
exports.MANIFEST_ATTACHMENT = MANIFEST_ATTACHMENT;
|
|
127
|
+
exports.MANIFEST_ATTACHMENT = require_upload.MANIFEST_ATTACHMENT;
|
|
690
128
|
exports.SCREEN_TAG_PREFIX = SCREEN_TAG_PREFIX;
|
|
691
|
-
exports.createRawGraphBundle = createRawGraphBundle;
|
|
129
|
+
exports.createRawGraphBundle = require_upload.createRawGraphBundle;
|
|
692
130
|
Object.defineProperty(exports, "expect", {
|
|
693
131
|
enumerable: true,
|
|
694
132
|
get: function() {
|
|
@@ -701,8 +139,9 @@ Object.defineProperty(exports, "mergeTests", {
|
|
|
701
139
|
return _playwright_test.mergeTests;
|
|
702
140
|
}
|
|
703
141
|
});
|
|
142
|
+
exports.prepareArtifactUpload = require_upload.prepareArtifactUpload;
|
|
704
143
|
exports.ralphProjects = ralphProjects;
|
|
705
144
|
exports.test = test;
|
|
706
|
-
exports.uploadRawGraph = uploadRawGraph;
|
|
145
|
+
exports.uploadRawGraph = require_upload.uploadRawGraph;
|
|
707
146
|
|
|
708
147
|
//# sourceMappingURL=index.cjs.map
|