@ralphralphai/playwright 1.9.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.mts +19 -2
- package/dist/index.mjs +1 -1
- package/dist/reporter.cjs +37 -11
- package/dist/reporter.cjs.map +1 -1
- package/dist/reporter.d.mts +2 -2
- package/dist/reporter.mjs +37 -11
- package/dist/reporter.mjs.map +1 -1
- package/dist/{upload-BQSCs8By.cjs → upload-BJ-OMhZ8.cjs} +281 -12
- package/dist/upload-BJ-OMhZ8.cjs.map +1 -0
- package/dist/{upload-BZs6Vqix.mjs → upload-BhHRgN8t.mjs} +277 -14
- package/dist/upload-BhHRgN8t.mjs.map +1 -0
- package/package.json +3 -3
- package/dist/upload-BQSCs8By.cjs.map +0 -1
- package/dist/upload-BZs6Vqix.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Untagged tests are untouched, and recording is off unless you turn it on.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
Requires Node.js 20.19+ and Playwright Test 1.
|
|
12
|
+
Requires Node.js 20.19+ and Playwright Test 1.63+ (major version 1).
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
15
|
pnpm add -D @ralphralphai/playwright @playwright/test
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_upload = require("./upload-
|
|
2
|
+
const require_upload = require("./upload-BJ-OMhZ8.cjs");
|
|
3
3
|
let node_path = require("node:path");
|
|
4
4
|
node_path = require_upload.__toESM(node_path, 1);
|
|
5
5
|
let _playwright_test = require("@playwright/test");
|
package/dist/index.d.mts
CHANGED
|
@@ -172,6 +172,15 @@ type RawNode = {
|
|
|
172
172
|
scale: 'css';
|
|
173
173
|
contentType: 'image/webp' | 'image/png';
|
|
174
174
|
};
|
|
175
|
+
/**
|
|
176
|
+
* The page's aria tree with document-space boxes, which Ralph diffs
|
|
177
|
+
* against the previous deployment. Absent when the capture failed or
|
|
178
|
+
* ran out of time, or the page's tree is too large to send.
|
|
179
|
+
*/
|
|
180
|
+
ariaSnapshot?: {
|
|
181
|
+
path: string;
|
|
182
|
+
attachmentName: string;
|
|
183
|
+
};
|
|
175
184
|
} | {
|
|
176
185
|
/** The page was visited but has no screenshot. */
|
|
177
186
|
status: 'uncaptured';
|
|
@@ -272,6 +281,10 @@ type RawGraphScreenshot = {
|
|
|
272
281
|
contentType: Screenshot['contentType'];
|
|
273
282
|
bytes: Uint8Array;
|
|
274
283
|
};
|
|
284
|
+
type RawGraphAriaSnapshot = {
|
|
285
|
+
nodeId: string;
|
|
286
|
+
bytes: Uint8Array;
|
|
287
|
+
};
|
|
275
288
|
/** An artifact the server reserved, which the upload and the manifest must name. */
|
|
276
289
|
type PreparedUpload = {
|
|
277
290
|
artifactId: string;
|
|
@@ -292,9 +305,13 @@ type RawGraphBundleIndex = {
|
|
|
292
305
|
nodeId: string;
|
|
293
306
|
path: string;
|
|
294
307
|
}[];
|
|
308
|
+
snapshots: {
|
|
309
|
+
nodeId: string;
|
|
310
|
+
path: string;
|
|
311
|
+
}[];
|
|
295
312
|
};
|
|
296
313
|
/** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */
|
|
297
|
-
declare function createRawGraphBundle(manifest: RawGraphManifest, screenshots: readonly RawGraphScreenshot[]): Promise<Uint8Array>;
|
|
314
|
+
declare function createRawGraphBundle(manifest: RawGraphManifest, screenshots: readonly RawGraphScreenshot[], snapshots?: readonly RawGraphAriaSnapshot[]): Promise<Uint8Array>;
|
|
298
315
|
/**
|
|
299
316
|
* Reserves an artifact for the run. Its `resultUrl` works from now on, so the
|
|
300
317
|
* run can be followed while it uploads and while Ralph processes it.
|
|
@@ -305,7 +322,7 @@ declare function prepareArtifactUpload(options: UploadOptions): Promise<Prepared
|
|
|
305
322
|
* `prepareArtifactUpload` has to have reserved. Ralph processes it after this
|
|
306
323
|
* returns; the result page shows when it is ready.
|
|
307
324
|
*/
|
|
308
|
-
declare function uploadRawGraph(manifest: RawGraphManifest, screenshots: readonly RawGraphScreenshot[], options: UploadOptions): Promise<UploadReceipt>;
|
|
325
|
+
declare function uploadRawGraph(manifest: RawGraphManifest, screenshots: readonly RawGraphScreenshot[], options: UploadOptions, snapshots?: readonly RawGraphAriaSnapshot[]): Promise<UploadReceipt>;
|
|
309
326
|
//#endregion
|
|
310
327
|
//#region src/recorder.d.ts
|
|
311
328
|
/** The attachment holding a test's `RawGraphFile`, which the reporter merges. */
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as prepareArtifactUpload, f as Recorder, g as resolveMode, h as loadConfigSync, i as createRawGraphBundle, l as uploadRawGraph, m as loadConfig, u as MANIFEST_ATTACHMENT } from "./upload-BhHRgN8t.mjs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { expect, mergeTests, test as test$1 } from "@playwright/test";
|
|
4
4
|
//#region src/screens.ts
|
package/dist/reporter.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const require_upload = require("./upload-
|
|
1
|
+
const require_upload = require("./upload-BJ-OMhZ8.cjs");
|
|
2
2
|
let node_path = require("node:path");
|
|
3
3
|
node_path = require_upload.__toESM(node_path, 1);
|
|
4
4
|
let node_fs_promises = require("node:fs/promises");
|
|
@@ -45,13 +45,17 @@ var RalphReporter = class {
|
|
|
45
45
|
...image,
|
|
46
46
|
bytes: typeof source === "string" ? await (0, node_fs_promises.readFile)(source) : source
|
|
47
47
|
})));
|
|
48
|
+
const snapshots = await Promise.all(merged.snapshots.map(async ({ nodeId, source }) => ({
|
|
49
|
+
nodeId,
|
|
50
|
+
bytes: typeof source === "string" ? await (0, node_fs_promises.readFile)(source) : source
|
|
51
|
+
})));
|
|
48
52
|
const uploading = require_upload.resolveMode(this.options) === "upload" || collected.some(({ file }) => file.mode === "upload");
|
|
49
53
|
let prepared;
|
|
50
54
|
if (uploading) {
|
|
51
55
|
try {
|
|
52
56
|
prepared = await this.reserve(merged.manifest);
|
|
53
57
|
} catch (error) {
|
|
54
|
-
const outputDir = await this.write(merged.manifest, screenshots);
|
|
58
|
+
const outputDir = await this.write(merged.manifest, screenshots, snapshots);
|
|
55
59
|
throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
|
|
56
60
|
}
|
|
57
61
|
log(`Ralph: uploading the run. Follow the upload and processing at ${require_upload.resultLink(prepared)}`);
|
|
@@ -61,10 +65,10 @@ var RalphReporter = class {
|
|
|
61
65
|
artifactId: prepared.artifactId
|
|
62
66
|
};
|
|
63
67
|
const nodeCount = manifest.rawGraphs.reduce((total, graph) => total + graph.nodes.length, 0);
|
|
64
|
-
const outputDir = await this.write(manifest, screenshots);
|
|
68
|
+
const outputDir = await this.write(manifest, screenshots, snapshots);
|
|
65
69
|
log(`Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`);
|
|
66
70
|
if (prepared !== void 0) {
|
|
67
|
-
const receipt = await require_upload.uploadRawGraph(manifest, screenshots, require_upload.resolveUploadOptions(this.options)).catch((error) => {
|
|
71
|
+
const receipt = await require_upload.uploadRawGraph(manifest, screenshots, require_upload.resolveUploadOptions(this.options), snapshots).catch((error) => {
|
|
68
72
|
throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
|
|
69
73
|
});
|
|
70
74
|
await (0, node_fs_promises.writeFile)(node_path.default.join(outputDir, "upload_receipt.json"), JSON.stringify(receipt, null, 2) + "\n");
|
|
@@ -88,7 +92,7 @@ var RalphReporter = class {
|
|
|
88
92
|
printsToStdio() {
|
|
89
93
|
return false;
|
|
90
94
|
}
|
|
91
|
-
/** Merges the tests into one manifest, with where to read their screenshots. */
|
|
95
|
+
/** Merges the tests into one manifest, with where to read their screenshots and snapshots. */
|
|
92
96
|
merge(collected) {
|
|
93
97
|
const [first] = collected;
|
|
94
98
|
const same = (pick, what) => {
|
|
@@ -98,6 +102,7 @@ var RalphReporter = class {
|
|
|
98
102
|
same((file) => file.config, "Ralph config");
|
|
99
103
|
same((file) => [file.runId, file.buildId], "runId and buildId");
|
|
100
104
|
const images = [];
|
|
105
|
+
const snapshots = [];
|
|
101
106
|
const rawGraphs = collected.map(({ file, status, attachments }) => {
|
|
102
107
|
const nodes = file.rawGraph.nodes.map((node) => {
|
|
103
108
|
if (node.status !== "recorded") return node;
|
|
@@ -108,12 +113,22 @@ var RalphReporter = class {
|
|
|
108
113
|
contentType: node.screenshot.contentType,
|
|
109
114
|
source
|
|
110
115
|
});
|
|
116
|
+
const snapshot = node.ariaSnapshot && attachments.get(node.ariaSnapshot.attachmentName);
|
|
117
|
+
if (snapshot !== void 0) snapshots.push({
|
|
118
|
+
nodeId: node.nodeId,
|
|
119
|
+
source: snapshot
|
|
120
|
+
});
|
|
121
|
+
const { ariaSnapshot, ...rest } = node;
|
|
111
122
|
return {
|
|
112
|
-
...
|
|
123
|
+
...rest,
|
|
113
124
|
screenshot: {
|
|
114
125
|
...node.screenshot,
|
|
115
126
|
path: require_upload.bundleImagePath(node.nodeId, node.screenshot.contentType)
|
|
116
|
-
}
|
|
127
|
+
},
|
|
128
|
+
...ariaSnapshot && snapshot !== void 0 && { ariaSnapshot: {
|
|
129
|
+
...ariaSnapshot,
|
|
130
|
+
path: require_upload.bundleSnapshotPath(node.nodeId)
|
|
131
|
+
} }
|
|
117
132
|
};
|
|
118
133
|
});
|
|
119
134
|
return {
|
|
@@ -141,11 +156,12 @@ var RalphReporter = class {
|
|
|
141
156
|
rawGraphs,
|
|
142
157
|
complete: rawGraphs.every((graph) => graph.complete)
|
|
143
158
|
},
|
|
144
|
-
images
|
|
159
|
+
images,
|
|
160
|
+
snapshots
|
|
145
161
|
};
|
|
146
162
|
}
|
|
147
|
-
/** Writes the manifest and
|
|
148
|
-
async write(manifest, screenshots) {
|
|
163
|
+
/** Writes the manifest, screenshots and snapshots in the bundle's layout. */
|
|
164
|
+
async write(manifest, screenshots, snapshots) {
|
|
149
165
|
const outputDir = node_path.default.resolve(this.options.outputDir ?? node_path.default.join(this.config?.projects[0]?.outputDir ?? node_path.default.join(this.config?.rootDir ?? process.cwd(), "test-results"), "ralph"));
|
|
150
166
|
await (0, node_fs_promises.rm)(outputDir, {
|
|
151
167
|
recursive: true,
|
|
@@ -153,7 +169,8 @@ var RalphReporter = class {
|
|
|
153
169
|
});
|
|
154
170
|
const index = {
|
|
155
171
|
manifest,
|
|
156
|
-
screenshots: []
|
|
172
|
+
screenshots: [],
|
|
173
|
+
snapshots: []
|
|
157
174
|
};
|
|
158
175
|
for (const { nodeId, contentType, bytes } of screenshots) {
|
|
159
176
|
const imagePath = require_upload.bundleImagePath(nodeId, contentType);
|
|
@@ -164,6 +181,15 @@ var RalphReporter = class {
|
|
|
164
181
|
await (0, node_fs_promises.mkdir)(node_path.default.dirname(node_path.default.join(outputDir, imagePath)), { recursive: true });
|
|
165
182
|
await (0, node_fs_promises.writeFile)(node_path.default.join(outputDir, imagePath), bytes);
|
|
166
183
|
}
|
|
184
|
+
for (const { nodeId, bytes } of snapshots) {
|
|
185
|
+
const snapshotPath = require_upload.bundleSnapshotPath(nodeId);
|
|
186
|
+
index.snapshots.push({
|
|
187
|
+
nodeId,
|
|
188
|
+
path: snapshotPath
|
|
189
|
+
});
|
|
190
|
+
await (0, node_fs_promises.mkdir)(node_path.default.dirname(node_path.default.join(outputDir, snapshotPath)), { recursive: true });
|
|
191
|
+
await (0, node_fs_promises.writeFile)(node_path.default.join(outputDir, snapshotPath), bytes);
|
|
192
|
+
}
|
|
167
193
|
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
168
194
|
await (0, node_fs_promises.writeFile)(node_path.default.join(outputDir, require_upload.RAW_GRAPH_BUNDLE_INDEX), JSON.stringify(index, null, 2) + "\n");
|
|
169
195
|
return outputDir;
|
package/dist/reporter.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reporter.cjs","names":["REPORTER_ENV","resolveMode","readFile","resultLink","uploadRawGraph","resolveUploadOptions","writeFile","path","prepareArtifactUpload","registerDeployment","bundleImagePath","playwrightPackage","randomUUID","rm","mkdir","RAW_GRAPH_BUNDLE_INDEX","MANIFEST_ATTACHMENT"],"sources":["../src/reporter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nimport packageInfo from '../package.json';\nimport { resolveMode } from './config';\nimport { MANIFEST_ATTACHMENT, REPORTER_ENV } from './recorder';\nimport type { RawGraphManifest, RawGraph, RawGraphFile } from './types';\nimport {\n RAW_GRAPH_BUNDLE_INDEX,\n prepareArtifactUpload,\n registerDeployment,\n resolveUploadOptions,\n resultLink,\n bundleImagePath,\n uploadRawGraph,\n type PreparedUpload,\n type RawGraphBundleIndex,\n type RawGraphScreenshot,\n} from './upload';\n\nexport type RalphReporterOptions = {\n /**\n * `upload` uploads the merged raw graph. Defaults to `RALPH_MODE`, else\n * `upload` when `RALPH_UPLOAD_KEY` is set. The run is also uploaded when any\n * test ran in `upload` mode.\n */\n mode?: 'off' | 'local' | 'upload';\n /** Your Ralph server. Defaults to `RALPH_API_URL`, else `https://api.ralphralph.ai`. */\n apiUrl?: string;\n /** The app the recordings belong to. Defaults to `RALPH_APP_ID`. */\n appId?: string;\n /** How long the upload may take, in milliseconds. Defaults to 120000. */\n uploadTimeoutMs?: number;\n /**\n * Upload the run as the graph of the deployed build `RALPH_BUILD_ID` names,\n * rather than as a standalone result. See `registerDeployment`. Defaults to\n * `RALPH_DEPLOY` being `1`.\n */\n deploy?: boolean;\n /**\n * Where the merged raw graph is written, laid out like the uploaded bundle.\n * Defaults to `ralph` inside the first project's output directory.\n */\n outputDir?: string;\n};\n\n/** One test's final attempt, as the reporter collected it. */\ntype Collected = {\n file: RawGraphFile;\n status: TestResult['status'];\n /** Screenshot attachment paths or preserved bodies, by attachment name. */\n attachments: Map<string, string | Buffer>;\n};\n\n/**\n * Merges every recorded test's raw graph into one for the whole run, writes it\n * locally, and uploads it once when the mode is `upload`.\n *\n * Tests run in worker processes, so only a reporter, in the main process,\n * sees them all. For a sharded run, it runs in `playwright merge-reports` over\n * the shards' blob reports.\n */\nexport default class RalphReporter implements Reporter {\n private readonly startedAt = new Date().toISOString();\n private config?: FullConfig;\n\n /**\n * By test id, so a retry replaces the attempt before it. Read as each test\n * ends, and awaited in `onEnd`.\n */\n private readonly tests = new Map<string, Promise<Collected | undefined>>();\n\n constructor(private readonly options: RalphReporterOptions = {}) {\n // Before any worker starts, so the fixtures can tell the reporter is on.\n process.env[REPORTER_ENV] = '1';\n if (resolveMode(options) === 'upload') {\n // Throws, so missing credentials stop the run before any test.\n resolveUploadOptions(options);\n }\n }\n\n onBegin(config: FullConfig): void {\n this.config = config;\n }\n\n onTestEnd(test: TestCase, result: TestResult): void {\n const collected = collect(result);\n // Rejections are reported in `onEnd`; this only stops Node from calling\n // them unhandled in the meantime.\n collected.catch(() => undefined);\n this.tests.set(test.id, collected);\n }\n\n async onEnd(): Promise<{ status?: FullResult['status'] } | undefined> {\n try {\n const collected = (await Promise.all(this.tests.values())).filter(\n (item) => item !== undefined,\n );\n if (collected.length === 0) {\n return undefined;\n }\n\n const merged = this.merge(collected);\n const screenshots = await Promise.all(\n merged.images.map(\n async ({ source, ...image }): Promise<RawGraphScreenshot> => ({\n ...image,\n bytes: typeof source === 'string' ? await readFile(source) : source,\n }),\n ),\n );\n const uploading =\n resolveMode(this.options) === 'upload' ||\n collected.some(({ file }) => file.mode === 'upload');\n\n // Before the local copy is written, so it names the same artifact as the\n // upload, and the link is printed as soon as it works.\n let prepared: PreparedUpload | undefined;\n if (uploading) {\n try {\n prepared = await this.reserve(merged.manifest);\n } catch (error) {\n const outputDir = await this.write(merged.manifest, screenshots);\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n }\n log(\n `Ralph: uploading the run. Follow the upload and processing at ${resultLink(prepared)}`,\n );\n }\n const manifest =\n prepared === undefined\n ? merged.manifest\n : { ...merged.manifest, artifactId: prepared.artifactId };\n const nodeCount = manifest.rawGraphs.reduce(\n (total, graph) => total + graph.nodes.length,\n 0,\n );\n const outputDir = await this.write(manifest, screenshots);\n log(\n `Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`,\n );\n\n if (prepared !== undefined) {\n const receipt = await uploadRawGraph(\n manifest,\n screenshots,\n resolveUploadOptions(this.options),\n ).catch((error: unknown) => {\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n });\n await writeFile(\n path.join(outputDir, 'upload_receipt.json'),\n JSON.stringify(receipt, null, 2) + '\\n',\n );\n log(\n `Ralph: uploaded the run. Ralph is processing it at ${resultLink(receipt)}`,\n );\n }\n return undefined;\n } catch (error) {\n console.error(\n 'Ralph: ' + (error instanceof Error ? error.message : String(error)),\n );\n return { status: 'failed' };\n }\n }\n\n private reserve(manifest: RawGraphManifest): Promise<PreparedUpload> {\n const options = resolveUploadOptions(this.options);\n const deploy = this.options.deploy ?? process.env['RALPH_DEPLOY'] === '1';\n if (!deploy) {\n return prepareArtifactUpload(options);\n }\n\n if (manifest.buildId === undefined) {\n throw new Error(\n 'A deployment run uploads into its build, so set RALPH_BUILD_ID (or the buildId option) to the deployed build.',\n );\n }\n return registerDeployment(options, {\n buildId: manifest.buildId,\n config: manifest.config.config,\n });\n }\n\n printsToStdio(): boolean {\n return false;\n }\n\n /** Merges the tests into one manifest, with where to read their screenshots. */\n private merge(collected: Collected[]): {\n manifest: RawGraphManifest;\n images: (Omit<RawGraphScreenshot, 'bytes'> & { source: string | Buffer })[];\n } {\n const [first] = collected;\n const same = (pick: (file: RawGraphFile) => unknown, what: string) => {\n const expected = JSON.stringify(pick(first!.file));\n if (\n collected.some(({ file }) => JSON.stringify(pick(file)) !== expected)\n ) {\n throw new Error(\n `Every recorded test in a run must share one ${what}; merge them per run.`,\n );\n }\n };\n same((file) => file.config, 'Ralph config');\n same((file) => [file.runId, file.buildId], 'runId and buildId');\n\n const images: (Omit<RawGraphScreenshot, 'bytes'> & {\n source: string | Buffer;\n })[] = [];\n const rawGraphs = collected\n .map(({ file, status, attachments }): RawGraph => {\n const nodes = file.rawGraph.nodes.map((node) => {\n if (node.status !== 'recorded') {\n return node;\n }\n const source = attachments.get(node.screenshot.attachmentName);\n if (source === undefined) {\n throw new Error(\n `The screenshot of node ${node.nodeId} is missing from the test results.`,\n );\n }\n images.push({\n nodeId: node.nodeId,\n contentType: node.screenshot.contentType,\n source,\n });\n return {\n ...node,\n screenshot: {\n ...node.screenshot,\n path: bundleImagePath(node.nodeId, node.screenshot.contentType),\n },\n };\n });\n return {\n ...file.rawGraph,\n // The final status, which includes failures after the recorder\n // finished, such as a later fixture's teardown.\n status,\n nodes,\n complete: file.rawGraph.complete && status === 'passed',\n };\n })\n .sort(\n (a, b) =>\n a.project.localeCompare(b.project) ||\n a.titlePath.join('\\0').localeCompare(b.titlePath.join('\\0')) ||\n a.repeatEachIndex - b.repeatEachIndex,\n );\n\n const manifest: RawGraphManifest = {\n formatVersion: 1,\n producer: {\n name: '@ralphralphai/playwright',\n version: packageInfo.version,\n playwrightVersion: playwrightPackage.version,\n },\n runId: first!.file.runId,\n buildId: first!.file.buildId,\n // Replaced by the prepared artifact's id when the run is uploaded.\n artifactId: randomUUID(),\n shard: this.config?.shard ?? null,\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\n config: first!.file.config,\n rawGraphs,\n complete: rawGraphs.every((graph) => graph.complete),\n };\n\n return {\n manifest,\n images,\n };\n }\n\n /** Writes the manifest and screenshots in the bundle's layout. */\n private async write(\n manifest: RawGraphManifest,\n screenshots: RawGraphScreenshot[],\n ): Promise<string> {\n const outputDir = path.resolve(\n this.options.outputDir ??\n path.join(\n this.config?.projects[0]?.outputDir ??\n path.join(this.config?.rootDir ?? process.cwd(), 'test-results'),\n 'ralph',\n ),\n );\n await rm(outputDir, { recursive: true, force: true });\n\n const index: RawGraphBundleIndex = { manifest, screenshots: [] };\n for (const { nodeId, contentType, bytes } of screenshots) {\n const imagePath = bundleImagePath(nodeId, contentType);\n index.screenshots.push({ nodeId, path: imagePath });\n await mkdir(path.dirname(path.join(outputDir, imagePath)), {\n recursive: true,\n });\n await writeFile(path.join(outputDir, imagePath), bytes);\n }\n await mkdir(outputDir, { recursive: true });\n await writeFile(\n path.join(outputDir, RAW_GRAPH_BUNDLE_INDEX),\n JSON.stringify(index, null, 2) + '\\n',\n );\n return outputDir;\n }\n}\n\n/** Reads a test's raw graph from its attachments; undefined if it recorded nothing. */\nasync function collect(result: TestResult): Promise<Collected | undefined> {\n const manifest = result.attachments.find(\n (item) => item.name === MANIFEST_ATTACHMENT,\n );\n if (!manifest || (!manifest.body && !manifest.path)) {\n return undefined;\n }\n const file = JSON.parse(\n manifest.body?.toString('utf8') ?? (await readFile(manifest.path!, 'utf8')),\n ) as RawGraphFile;\n const attachments = new Map<string, string | Buffer>();\n for (const { name, path: attachmentPath, body } of result.attachments) {\n const source = body ?? attachmentPath;\n if (source !== undefined) {\n attachments.set(name, source);\n }\n }\n return { file, status: result.status, attachments };\n}\n\nfunction log(message: string): void {\n process.stdout.write(message + '\\n');\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"reporter.cjs","names":["REPORTER_ENV","resolveMode","readFile","resultLink","uploadRawGraph","resolveUploadOptions","writeFile","path","prepareArtifactUpload","registerDeployment","bundleImagePath","bundleSnapshotPath","playwrightPackage","randomUUID","rm","mkdir","RAW_GRAPH_BUNDLE_INDEX","MANIFEST_ATTACHMENT"],"sources":["../src/reporter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nimport packageInfo from '../package.json';\nimport { resolveMode } from './config';\nimport { MANIFEST_ATTACHMENT, REPORTER_ENV } from './recorder';\nimport type { RawGraphManifest, RawGraph, RawGraphFile } from './types';\nimport {\n RAW_GRAPH_BUNDLE_INDEX,\n prepareArtifactUpload,\n registerDeployment,\n resolveUploadOptions,\n resultLink,\n bundleImagePath,\n bundleSnapshotPath,\n uploadRawGraph,\n type PreparedUpload,\n type RawGraphAriaSnapshot,\n type RawGraphBundleIndex,\n type RawGraphScreenshot,\n} from './upload';\n\nexport type RalphReporterOptions = {\n /**\n * `upload` uploads the merged raw graph. Defaults to `RALPH_MODE`, else\n * `upload` when `RALPH_UPLOAD_KEY` is set. The run is also uploaded when any\n * test ran in `upload` mode.\n */\n mode?: 'off' | 'local' | 'upload';\n /** Your Ralph server. Defaults to `RALPH_API_URL`, else `https://api.ralphralph.ai`. */\n apiUrl?: string;\n /** The app the recordings belong to. Defaults to `RALPH_APP_ID`. */\n appId?: string;\n /** How long the upload may take, in milliseconds. Defaults to 120000. */\n uploadTimeoutMs?: number;\n /**\n * Upload the run as the graph of the deployed build `RALPH_BUILD_ID` names,\n * rather than as a standalone result. See `registerDeployment`. Defaults to\n * `RALPH_DEPLOY` being `1`.\n */\n deploy?: boolean;\n /**\n * Where the merged raw graph is written, laid out like the uploaded bundle.\n * Defaults to `ralph` inside the first project's output directory.\n */\n outputDir?: string;\n};\n\n/** One test's final attempt, as the reporter collected it. */\ntype Collected = {\n file: RawGraphFile;\n status: TestResult['status'];\n /** Screenshot and snapshot attachment paths or preserved bodies, by attachment name. */\n attachments: Map<string, string | Buffer>;\n};\n\n/**\n * Merges every recorded test's raw graph into one for the whole run, writes it\n * locally, and uploads it once when the mode is `upload`.\n *\n * Tests run in worker processes, so only a reporter, in the main process,\n * sees them all. For a sharded run, it runs in `playwright merge-reports` over\n * the shards' blob reports.\n */\nexport default class RalphReporter implements Reporter {\n private readonly startedAt = new Date().toISOString();\n private config?: FullConfig;\n\n /**\n * By test id, so a retry replaces the attempt before it. Read as each test\n * ends, and awaited in `onEnd`.\n */\n private readonly tests = new Map<string, Promise<Collected | undefined>>();\n\n constructor(private readonly options: RalphReporterOptions = {}) {\n // Before any worker starts, so the fixtures can tell the reporter is on.\n process.env[REPORTER_ENV] = '1';\n if (resolveMode(options) === 'upload') {\n // Throws, so missing credentials stop the run before any test.\n resolveUploadOptions(options);\n }\n }\n\n onBegin(config: FullConfig): void {\n this.config = config;\n }\n\n onTestEnd(test: TestCase, result: TestResult): void {\n const collected = collect(result);\n // Rejections are reported in `onEnd`; this only stops Node from calling\n // them unhandled in the meantime.\n collected.catch(() => undefined);\n this.tests.set(test.id, collected);\n }\n\n async onEnd(): Promise<{ status?: FullResult['status'] } | undefined> {\n try {\n const collected = (await Promise.all(this.tests.values())).filter(\n (item) => item !== undefined,\n );\n if (collected.length === 0) {\n return undefined;\n }\n\n const merged = this.merge(collected);\n const screenshots = await Promise.all(\n merged.images.map(\n async ({ source, ...image }): Promise<RawGraphScreenshot> => ({\n ...image,\n bytes: typeof source === 'string' ? await readFile(source) : source,\n }),\n ),\n );\n const snapshots = await Promise.all(\n merged.snapshots.map(\n async ({ nodeId, source }): Promise<RawGraphAriaSnapshot> => ({\n nodeId,\n bytes: typeof source === 'string' ? await readFile(source) : source,\n }),\n ),\n );\n const uploading =\n resolveMode(this.options) === 'upload' ||\n collected.some(({ file }) => file.mode === 'upload');\n\n // Before the local copy is written, so it names the same artifact as the\n // upload, and the link is printed as soon as it works.\n let prepared: PreparedUpload | undefined;\n if (uploading) {\n try {\n prepared = await this.reserve(merged.manifest);\n } catch (error) {\n const outputDir = await this.write(\n merged.manifest,\n screenshots,\n snapshots,\n );\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n }\n log(\n `Ralph: uploading the run. Follow the upload and processing at ${resultLink(prepared)}`,\n );\n }\n const manifest =\n prepared === undefined\n ? merged.manifest\n : { ...merged.manifest, artifactId: prepared.artifactId };\n const nodeCount = manifest.rawGraphs.reduce(\n (total, graph) => total + graph.nodes.length,\n 0,\n );\n const outputDir = await this.write(manifest, screenshots, snapshots);\n log(\n `Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`,\n );\n\n if (prepared !== undefined) {\n const receipt = await uploadRawGraph(\n manifest,\n screenshots,\n resolveUploadOptions(this.options),\n snapshots,\n ).catch((error: unknown) => {\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n });\n await writeFile(\n path.join(outputDir, 'upload_receipt.json'),\n JSON.stringify(receipt, null, 2) + '\\n',\n );\n log(\n `Ralph: uploaded the run. Ralph is processing it at ${resultLink(receipt)}`,\n );\n }\n return undefined;\n } catch (error) {\n console.error(\n 'Ralph: ' + (error instanceof Error ? error.message : String(error)),\n );\n return { status: 'failed' };\n }\n }\n\n private reserve(manifest: RawGraphManifest): Promise<PreparedUpload> {\n const options = resolveUploadOptions(this.options);\n const deploy = this.options.deploy ?? process.env['RALPH_DEPLOY'] === '1';\n if (!deploy) {\n return prepareArtifactUpload(options);\n }\n\n if (manifest.buildId === undefined) {\n throw new Error(\n 'A deployment run uploads into its build, so set RALPH_BUILD_ID (or the buildId option) to the deployed build.',\n );\n }\n return registerDeployment(options, {\n buildId: manifest.buildId,\n config: manifest.config.config,\n });\n }\n\n printsToStdio(): boolean {\n return false;\n }\n\n /** Merges the tests into one manifest, with where to read their screenshots and snapshots. */\n private merge(collected: Collected[]): {\n manifest: RawGraphManifest;\n images: (Omit<RawGraphScreenshot, 'bytes'> & { source: string | Buffer })[];\n snapshots: { nodeId: string; source: string | Buffer }[];\n } {\n const [first] = collected;\n const same = (pick: (file: RawGraphFile) => unknown, what: string) => {\n const expected = JSON.stringify(pick(first!.file));\n if (\n collected.some(({ file }) => JSON.stringify(pick(file)) !== expected)\n ) {\n throw new Error(\n `Every recorded test in a run must share one ${what}; merge them per run.`,\n );\n }\n };\n same((file) => file.config, 'Ralph config');\n same((file) => [file.runId, file.buildId], 'runId and buildId');\n\n const images: (Omit<RawGraphScreenshot, 'bytes'> & {\n source: string | Buffer;\n })[] = [];\n const snapshots: { nodeId: string; source: string | Buffer }[] = [];\n const rawGraphs = collected\n .map(({ file, status, attachments }): RawGraph => {\n const nodes = file.rawGraph.nodes.map((node) => {\n if (node.status !== 'recorded') {\n return node;\n }\n const source = attachments.get(node.screenshot.attachmentName);\n if (source === undefined) {\n throw new Error(\n `The screenshot of node ${node.nodeId} is missing from the test results.`,\n );\n }\n images.push({\n nodeId: node.nodeId,\n contentType: node.screenshot.contentType,\n source,\n });\n // A snapshot is optional, so one missing from the results drops\n // from the node rather than failing the run.\n const snapshot =\n node.ariaSnapshot &&\n attachments.get(node.ariaSnapshot.attachmentName);\n if (snapshot !== undefined) {\n snapshots.push({ nodeId: node.nodeId, source: snapshot });\n }\n const { ariaSnapshot, ...rest } = node;\n return {\n ...rest,\n screenshot: {\n ...node.screenshot,\n path: bundleImagePath(node.nodeId, node.screenshot.contentType),\n },\n ...(ariaSnapshot &&\n snapshot !== undefined && {\n ariaSnapshot: {\n ...ariaSnapshot,\n path: bundleSnapshotPath(node.nodeId),\n },\n }),\n };\n });\n return {\n ...file.rawGraph,\n // The final status, which includes failures after the recorder\n // finished, such as a later fixture's teardown.\n status,\n nodes,\n complete: file.rawGraph.complete && status === 'passed',\n };\n })\n .sort(\n (a, b) =>\n a.project.localeCompare(b.project) ||\n a.titlePath.join('\\0').localeCompare(b.titlePath.join('\\0')) ||\n a.repeatEachIndex - b.repeatEachIndex,\n );\n\n const manifest: RawGraphManifest = {\n formatVersion: 1,\n producer: {\n name: '@ralphralphai/playwright',\n version: packageInfo.version,\n playwrightVersion: playwrightPackage.version,\n },\n runId: first!.file.runId,\n buildId: first!.file.buildId,\n // Replaced by the prepared artifact's id when the run is uploaded.\n artifactId: randomUUID(),\n shard: this.config?.shard ?? null,\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\n config: first!.file.config,\n rawGraphs,\n complete: rawGraphs.every((graph) => graph.complete),\n };\n\n return {\n manifest,\n images,\n snapshots,\n };\n }\n\n /** Writes the manifest, screenshots and snapshots in the bundle's layout. */\n private async write(\n manifest: RawGraphManifest,\n screenshots: RawGraphScreenshot[],\n snapshots: RawGraphAriaSnapshot[],\n ): Promise<string> {\n const outputDir = path.resolve(\n this.options.outputDir ??\n path.join(\n this.config?.projects[0]?.outputDir ??\n path.join(this.config?.rootDir ?? process.cwd(), 'test-results'),\n 'ralph',\n ),\n );\n await rm(outputDir, { recursive: true, force: true });\n\n const index: RawGraphBundleIndex = {\n manifest,\n screenshots: [],\n snapshots: [],\n };\n for (const { nodeId, contentType, bytes } of screenshots) {\n const imagePath = bundleImagePath(nodeId, contentType);\n index.screenshots.push({ nodeId, path: imagePath });\n await mkdir(path.dirname(path.join(outputDir, imagePath)), {\n recursive: true,\n });\n await writeFile(path.join(outputDir, imagePath), bytes);\n }\n for (const { nodeId, bytes } of snapshots) {\n const snapshotPath = bundleSnapshotPath(nodeId);\n index.snapshots.push({ nodeId, path: snapshotPath });\n await mkdir(path.dirname(path.join(outputDir, snapshotPath)), {\n recursive: true,\n });\n await writeFile(path.join(outputDir, snapshotPath), bytes);\n }\n await mkdir(outputDir, { recursive: true });\n await writeFile(\n path.join(outputDir, RAW_GRAPH_BUNDLE_INDEX),\n JSON.stringify(index, null, 2) + '\\n',\n );\n return outputDir;\n }\n}\n\n/** Reads a test's raw graph from its attachments; undefined if it recorded nothing. */\nasync function collect(result: TestResult): Promise<Collected | undefined> {\n const manifest = result.attachments.find(\n (item) => item.name === MANIFEST_ATTACHMENT,\n );\n if (!manifest || (!manifest.body && !manifest.path)) {\n return undefined;\n }\n const file = JSON.parse(\n manifest.body?.toString('utf8') ?? (await readFile(manifest.path!, 'utf8')),\n ) as RawGraphFile;\n const attachments = new Map<string, string | Buffer>();\n for (const { name, path: attachmentPath, body } of result.attachments) {\n const source = body ?? attachmentPath;\n if (source !== undefined) {\n attachments.set(name, source);\n }\n }\n return { file, status: result.status, attachments };\n}\n\nfunction log(message: string): void {\n process.stdout.write(message + '\\n');\n}\n"],"mappings":"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"}
|
package/dist/reporter.d.mts
CHANGED
|
@@ -50,9 +50,9 @@ declare class RalphReporter implements Reporter {
|
|
|
50
50
|
} | undefined>;
|
|
51
51
|
private reserve;
|
|
52
52
|
printsToStdio(): boolean;
|
|
53
|
-
/** Merges the tests into one manifest, with where to read their screenshots. */
|
|
53
|
+
/** Merges the tests into one manifest, with where to read their screenshots and snapshots. */
|
|
54
54
|
private merge;
|
|
55
|
-
/** Writes the manifest and
|
|
55
|
+
/** Writes the manifest, screenshots and snapshots in the bundle's layout. */
|
|
56
56
|
private write;
|
|
57
57
|
}
|
|
58
58
|
//#endregion
|
package/dist/reporter.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as prepareArtifactUpload, c as resultLink, d as REPORTER_ENV, g as resolveMode, l as uploadRawGraph, n as bundleImagePath, o as registerDeployment, p as version, r as bundleSnapshotPath, s as resolveUploadOptions, t as RAW_GRAPH_BUNDLE_INDEX, u as MANIFEST_ATTACHMENT } from "./upload-BhHRgN8t.mjs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -43,13 +43,17 @@ var RalphReporter = class {
|
|
|
43
43
|
...image,
|
|
44
44
|
bytes: typeof source === "string" ? await readFile(source) : source
|
|
45
45
|
})));
|
|
46
|
+
const snapshots = await Promise.all(merged.snapshots.map(async ({ nodeId, source }) => ({
|
|
47
|
+
nodeId,
|
|
48
|
+
bytes: typeof source === "string" ? await readFile(source) : source
|
|
49
|
+
})));
|
|
46
50
|
const uploading = resolveMode(this.options) === "upload" || collected.some(({ file }) => file.mode === "upload");
|
|
47
51
|
let prepared;
|
|
48
52
|
if (uploading) {
|
|
49
53
|
try {
|
|
50
54
|
prepared = await this.reserve(merged.manifest);
|
|
51
55
|
} catch (error) {
|
|
52
|
-
const outputDir = await this.write(merged.manifest, screenshots);
|
|
56
|
+
const outputDir = await this.write(merged.manifest, screenshots, snapshots);
|
|
53
57
|
throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
|
|
54
58
|
}
|
|
55
59
|
log(`Ralph: uploading the run. Follow the upload and processing at ${resultLink(prepared)}`);
|
|
@@ -59,10 +63,10 @@ var RalphReporter = class {
|
|
|
59
63
|
artifactId: prepared.artifactId
|
|
60
64
|
};
|
|
61
65
|
const nodeCount = manifest.rawGraphs.reduce((total, graph) => total + graph.nodes.length, 0);
|
|
62
|
-
const outputDir = await this.write(manifest, screenshots);
|
|
66
|
+
const outputDir = await this.write(manifest, screenshots, snapshots);
|
|
63
67
|
log(`Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`);
|
|
64
68
|
if (prepared !== void 0) {
|
|
65
|
-
const receipt = await uploadRawGraph(manifest, screenshots, resolveUploadOptions(this.options)).catch((error) => {
|
|
69
|
+
const receipt = await uploadRawGraph(manifest, screenshots, resolveUploadOptions(this.options), snapshots).catch((error) => {
|
|
66
70
|
throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
|
|
67
71
|
});
|
|
68
72
|
await writeFile(path.join(outputDir, "upload_receipt.json"), JSON.stringify(receipt, null, 2) + "\n");
|
|
@@ -86,7 +90,7 @@ var RalphReporter = class {
|
|
|
86
90
|
printsToStdio() {
|
|
87
91
|
return false;
|
|
88
92
|
}
|
|
89
|
-
/** Merges the tests into one manifest, with where to read their screenshots. */
|
|
93
|
+
/** Merges the tests into one manifest, with where to read their screenshots and snapshots. */
|
|
90
94
|
merge(collected) {
|
|
91
95
|
const [first] = collected;
|
|
92
96
|
const same = (pick, what) => {
|
|
@@ -96,6 +100,7 @@ var RalphReporter = class {
|
|
|
96
100
|
same((file) => file.config, "Ralph config");
|
|
97
101
|
same((file) => [file.runId, file.buildId], "runId and buildId");
|
|
98
102
|
const images = [];
|
|
103
|
+
const snapshots = [];
|
|
99
104
|
const rawGraphs = collected.map(({ file, status, attachments }) => {
|
|
100
105
|
const nodes = file.rawGraph.nodes.map((node) => {
|
|
101
106
|
if (node.status !== "recorded") return node;
|
|
@@ -106,12 +111,22 @@ var RalphReporter = class {
|
|
|
106
111
|
contentType: node.screenshot.contentType,
|
|
107
112
|
source
|
|
108
113
|
});
|
|
114
|
+
const snapshot = node.ariaSnapshot && attachments.get(node.ariaSnapshot.attachmentName);
|
|
115
|
+
if (snapshot !== void 0) snapshots.push({
|
|
116
|
+
nodeId: node.nodeId,
|
|
117
|
+
source: snapshot
|
|
118
|
+
});
|
|
119
|
+
const { ariaSnapshot, ...rest } = node;
|
|
109
120
|
return {
|
|
110
|
-
...
|
|
121
|
+
...rest,
|
|
111
122
|
screenshot: {
|
|
112
123
|
...node.screenshot,
|
|
113
124
|
path: bundleImagePath(node.nodeId, node.screenshot.contentType)
|
|
114
|
-
}
|
|
125
|
+
},
|
|
126
|
+
...ariaSnapshot && snapshot !== void 0 && { ariaSnapshot: {
|
|
127
|
+
...ariaSnapshot,
|
|
128
|
+
path: bundleSnapshotPath(node.nodeId)
|
|
129
|
+
} }
|
|
115
130
|
};
|
|
116
131
|
});
|
|
117
132
|
return {
|
|
@@ -139,11 +154,12 @@ var RalphReporter = class {
|
|
|
139
154
|
rawGraphs,
|
|
140
155
|
complete: rawGraphs.every((graph) => graph.complete)
|
|
141
156
|
},
|
|
142
|
-
images
|
|
157
|
+
images,
|
|
158
|
+
snapshots
|
|
143
159
|
};
|
|
144
160
|
}
|
|
145
|
-
/** Writes the manifest and
|
|
146
|
-
async write(manifest, screenshots) {
|
|
161
|
+
/** Writes the manifest, screenshots and snapshots in the bundle's layout. */
|
|
162
|
+
async write(manifest, screenshots, snapshots) {
|
|
147
163
|
const outputDir = path.resolve(this.options.outputDir ?? path.join(this.config?.projects[0]?.outputDir ?? path.join(this.config?.rootDir ?? process.cwd(), "test-results"), "ralph"));
|
|
148
164
|
await rm(outputDir, {
|
|
149
165
|
recursive: true,
|
|
@@ -151,7 +167,8 @@ var RalphReporter = class {
|
|
|
151
167
|
});
|
|
152
168
|
const index = {
|
|
153
169
|
manifest,
|
|
154
|
-
screenshots: []
|
|
170
|
+
screenshots: [],
|
|
171
|
+
snapshots: []
|
|
155
172
|
};
|
|
156
173
|
for (const { nodeId, contentType, bytes } of screenshots) {
|
|
157
174
|
const imagePath = bundleImagePath(nodeId, contentType);
|
|
@@ -162,6 +179,15 @@ var RalphReporter = class {
|
|
|
162
179
|
await mkdir(path.dirname(path.join(outputDir, imagePath)), { recursive: true });
|
|
163
180
|
await writeFile(path.join(outputDir, imagePath), bytes);
|
|
164
181
|
}
|
|
182
|
+
for (const { nodeId, bytes } of snapshots) {
|
|
183
|
+
const snapshotPath = bundleSnapshotPath(nodeId);
|
|
184
|
+
index.snapshots.push({
|
|
185
|
+
nodeId,
|
|
186
|
+
path: snapshotPath
|
|
187
|
+
});
|
|
188
|
+
await mkdir(path.dirname(path.join(outputDir, snapshotPath)), { recursive: true });
|
|
189
|
+
await writeFile(path.join(outputDir, snapshotPath), bytes);
|
|
190
|
+
}
|
|
165
191
|
await mkdir(outputDir, { recursive: true });
|
|
166
192
|
await writeFile(path.join(outputDir, RAW_GRAPH_BUNDLE_INDEX), JSON.stringify(index, null, 2) + "\n");
|
|
167
193
|
return outputDir;
|
package/dist/reporter.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reporter.mjs","names":["packageInfo.version"],"sources":["../src/reporter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nimport packageInfo from '../package.json';\nimport { resolveMode } from './config';\nimport { MANIFEST_ATTACHMENT, REPORTER_ENV } from './recorder';\nimport type { RawGraphManifest, RawGraph, RawGraphFile } from './types';\nimport {\n RAW_GRAPH_BUNDLE_INDEX,\n prepareArtifactUpload,\n registerDeployment,\n resolveUploadOptions,\n resultLink,\n bundleImagePath,\n uploadRawGraph,\n type PreparedUpload,\n type RawGraphBundleIndex,\n type RawGraphScreenshot,\n} from './upload';\n\nexport type RalphReporterOptions = {\n /**\n * `upload` uploads the merged raw graph. Defaults to `RALPH_MODE`, else\n * `upload` when `RALPH_UPLOAD_KEY` is set. The run is also uploaded when any\n * test ran in `upload` mode.\n */\n mode?: 'off' | 'local' | 'upload';\n /** Your Ralph server. Defaults to `RALPH_API_URL`, else `https://api.ralphralph.ai`. */\n apiUrl?: string;\n /** The app the recordings belong to. Defaults to `RALPH_APP_ID`. */\n appId?: string;\n /** How long the upload may take, in milliseconds. Defaults to 120000. */\n uploadTimeoutMs?: number;\n /**\n * Upload the run as the graph of the deployed build `RALPH_BUILD_ID` names,\n * rather than as a standalone result. See `registerDeployment`. Defaults to\n * `RALPH_DEPLOY` being `1`.\n */\n deploy?: boolean;\n /**\n * Where the merged raw graph is written, laid out like the uploaded bundle.\n * Defaults to `ralph` inside the first project's output directory.\n */\n outputDir?: string;\n};\n\n/** One test's final attempt, as the reporter collected it. */\ntype Collected = {\n file: RawGraphFile;\n status: TestResult['status'];\n /** Screenshot attachment paths or preserved bodies, by attachment name. */\n attachments: Map<string, string | Buffer>;\n};\n\n/**\n * Merges every recorded test's raw graph into one for the whole run, writes it\n * locally, and uploads it once when the mode is `upload`.\n *\n * Tests run in worker processes, so only a reporter, in the main process,\n * sees them all. For a sharded run, it runs in `playwright merge-reports` over\n * the shards' blob reports.\n */\nexport default class RalphReporter implements Reporter {\n private readonly startedAt = new Date().toISOString();\n private config?: FullConfig;\n\n /**\n * By test id, so a retry replaces the attempt before it. Read as each test\n * ends, and awaited in `onEnd`.\n */\n private readonly tests = new Map<string, Promise<Collected | undefined>>();\n\n constructor(private readonly options: RalphReporterOptions = {}) {\n // Before any worker starts, so the fixtures can tell the reporter is on.\n process.env[REPORTER_ENV] = '1';\n if (resolveMode(options) === 'upload') {\n // Throws, so missing credentials stop the run before any test.\n resolveUploadOptions(options);\n }\n }\n\n onBegin(config: FullConfig): void {\n this.config = config;\n }\n\n onTestEnd(test: TestCase, result: TestResult): void {\n const collected = collect(result);\n // Rejections are reported in `onEnd`; this only stops Node from calling\n // them unhandled in the meantime.\n collected.catch(() => undefined);\n this.tests.set(test.id, collected);\n }\n\n async onEnd(): Promise<{ status?: FullResult['status'] } | undefined> {\n try {\n const collected = (await Promise.all(this.tests.values())).filter(\n (item) => item !== undefined,\n );\n if (collected.length === 0) {\n return undefined;\n }\n\n const merged = this.merge(collected);\n const screenshots = await Promise.all(\n merged.images.map(\n async ({ source, ...image }): Promise<RawGraphScreenshot> => ({\n ...image,\n bytes: typeof source === 'string' ? await readFile(source) : source,\n }),\n ),\n );\n const uploading =\n resolveMode(this.options) === 'upload' ||\n collected.some(({ file }) => file.mode === 'upload');\n\n // Before the local copy is written, so it names the same artifact as the\n // upload, and the link is printed as soon as it works.\n let prepared: PreparedUpload | undefined;\n if (uploading) {\n try {\n prepared = await this.reserve(merged.manifest);\n } catch (error) {\n const outputDir = await this.write(merged.manifest, screenshots);\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n }\n log(\n `Ralph: uploading the run. Follow the upload and processing at ${resultLink(prepared)}`,\n );\n }\n const manifest =\n prepared === undefined\n ? merged.manifest\n : { ...merged.manifest, artifactId: prepared.artifactId };\n const nodeCount = manifest.rawGraphs.reduce(\n (total, graph) => total + graph.nodes.length,\n 0,\n );\n const outputDir = await this.write(manifest, screenshots);\n log(\n `Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`,\n );\n\n if (prepared !== undefined) {\n const receipt = await uploadRawGraph(\n manifest,\n screenshots,\n resolveUploadOptions(this.options),\n ).catch((error: unknown) => {\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n });\n await writeFile(\n path.join(outputDir, 'upload_receipt.json'),\n JSON.stringify(receipt, null, 2) + '\\n',\n );\n log(\n `Ralph: uploaded the run. Ralph is processing it at ${resultLink(receipt)}`,\n );\n }\n return undefined;\n } catch (error) {\n console.error(\n 'Ralph: ' + (error instanceof Error ? error.message : String(error)),\n );\n return { status: 'failed' };\n }\n }\n\n private reserve(manifest: RawGraphManifest): Promise<PreparedUpload> {\n const options = resolveUploadOptions(this.options);\n const deploy = this.options.deploy ?? process.env['RALPH_DEPLOY'] === '1';\n if (!deploy) {\n return prepareArtifactUpload(options);\n }\n\n if (manifest.buildId === undefined) {\n throw new Error(\n 'A deployment run uploads into its build, so set RALPH_BUILD_ID (or the buildId option) to the deployed build.',\n );\n }\n return registerDeployment(options, {\n buildId: manifest.buildId,\n config: manifest.config.config,\n });\n }\n\n printsToStdio(): boolean {\n return false;\n }\n\n /** Merges the tests into one manifest, with where to read their screenshots. */\n private merge(collected: Collected[]): {\n manifest: RawGraphManifest;\n images: (Omit<RawGraphScreenshot, 'bytes'> & { source: string | Buffer })[];\n } {\n const [first] = collected;\n const same = (pick: (file: RawGraphFile) => unknown, what: string) => {\n const expected = JSON.stringify(pick(first!.file));\n if (\n collected.some(({ file }) => JSON.stringify(pick(file)) !== expected)\n ) {\n throw new Error(\n `Every recorded test in a run must share one ${what}; merge them per run.`,\n );\n }\n };\n same((file) => file.config, 'Ralph config');\n same((file) => [file.runId, file.buildId], 'runId and buildId');\n\n const images: (Omit<RawGraphScreenshot, 'bytes'> & {\n source: string | Buffer;\n })[] = [];\n const rawGraphs = collected\n .map(({ file, status, attachments }): RawGraph => {\n const nodes = file.rawGraph.nodes.map((node) => {\n if (node.status !== 'recorded') {\n return node;\n }\n const source = attachments.get(node.screenshot.attachmentName);\n if (source === undefined) {\n throw new Error(\n `The screenshot of node ${node.nodeId} is missing from the test results.`,\n );\n }\n images.push({\n nodeId: node.nodeId,\n contentType: node.screenshot.contentType,\n source,\n });\n return {\n ...node,\n screenshot: {\n ...node.screenshot,\n path: bundleImagePath(node.nodeId, node.screenshot.contentType),\n },\n };\n });\n return {\n ...file.rawGraph,\n // The final status, which includes failures after the recorder\n // finished, such as a later fixture's teardown.\n status,\n nodes,\n complete: file.rawGraph.complete && status === 'passed',\n };\n })\n .sort(\n (a, b) =>\n a.project.localeCompare(b.project) ||\n a.titlePath.join('\\0').localeCompare(b.titlePath.join('\\0')) ||\n a.repeatEachIndex - b.repeatEachIndex,\n );\n\n const manifest: RawGraphManifest = {\n formatVersion: 1,\n producer: {\n name: '@ralphralphai/playwright',\n version: packageInfo.version,\n playwrightVersion: playwrightPackage.version,\n },\n runId: first!.file.runId,\n buildId: first!.file.buildId,\n // Replaced by the prepared artifact's id when the run is uploaded.\n artifactId: randomUUID(),\n shard: this.config?.shard ?? null,\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\n config: first!.file.config,\n rawGraphs,\n complete: rawGraphs.every((graph) => graph.complete),\n };\n\n return {\n manifest,\n images,\n };\n }\n\n /** Writes the manifest and screenshots in the bundle's layout. */\n private async write(\n manifest: RawGraphManifest,\n screenshots: RawGraphScreenshot[],\n ): Promise<string> {\n const outputDir = path.resolve(\n this.options.outputDir ??\n path.join(\n this.config?.projects[0]?.outputDir ??\n path.join(this.config?.rootDir ?? process.cwd(), 'test-results'),\n 'ralph',\n ),\n );\n await rm(outputDir, { recursive: true, force: true });\n\n const index: RawGraphBundleIndex = { manifest, screenshots: [] };\n for (const { nodeId, contentType, bytes } of screenshots) {\n const imagePath = bundleImagePath(nodeId, contentType);\n index.screenshots.push({ nodeId, path: imagePath });\n await mkdir(path.dirname(path.join(outputDir, imagePath)), {\n recursive: true,\n });\n await writeFile(path.join(outputDir, imagePath), bytes);\n }\n await mkdir(outputDir, { recursive: true });\n await writeFile(\n path.join(outputDir, RAW_GRAPH_BUNDLE_INDEX),\n JSON.stringify(index, null, 2) + '\\n',\n );\n return outputDir;\n }\n}\n\n/** Reads a test's raw graph from its attachments; undefined if it recorded nothing. */\nasync function collect(result: TestResult): Promise<Collected | undefined> {\n const manifest = result.attachments.find(\n (item) => item.name === MANIFEST_ATTACHMENT,\n );\n if (!manifest || (!manifest.body && !manifest.path)) {\n return undefined;\n }\n const file = JSON.parse(\n manifest.body?.toString('utf8') ?? (await readFile(manifest.path!, 'utf8')),\n ) as RawGraphFile;\n const attachments = new Map<string, string | Buffer>();\n for (const { name, path: attachmentPath, body } of result.attachments) {\n const source = body ?? attachmentPath;\n if (source !== undefined) {\n attachments.set(name, source);\n }\n }\n return { file, status: result.status, attachments };\n}\n\nfunction log(message: string): void {\n process.stdout.write(message + '\\n');\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"reporter.mjs","names":["packageInfo.version"],"sources":["../src/reporter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nimport packageInfo from '../package.json';\nimport { resolveMode } from './config';\nimport { MANIFEST_ATTACHMENT, REPORTER_ENV } from './recorder';\nimport type { RawGraphManifest, RawGraph, RawGraphFile } from './types';\nimport {\n RAW_GRAPH_BUNDLE_INDEX,\n prepareArtifactUpload,\n registerDeployment,\n resolveUploadOptions,\n resultLink,\n bundleImagePath,\n bundleSnapshotPath,\n uploadRawGraph,\n type PreparedUpload,\n type RawGraphAriaSnapshot,\n type RawGraphBundleIndex,\n type RawGraphScreenshot,\n} from './upload';\n\nexport type RalphReporterOptions = {\n /**\n * `upload` uploads the merged raw graph. Defaults to `RALPH_MODE`, else\n * `upload` when `RALPH_UPLOAD_KEY` is set. The run is also uploaded when any\n * test ran in `upload` mode.\n */\n mode?: 'off' | 'local' | 'upload';\n /** Your Ralph server. Defaults to `RALPH_API_URL`, else `https://api.ralphralph.ai`. */\n apiUrl?: string;\n /** The app the recordings belong to. Defaults to `RALPH_APP_ID`. */\n appId?: string;\n /** How long the upload may take, in milliseconds. Defaults to 120000. */\n uploadTimeoutMs?: number;\n /**\n * Upload the run as the graph of the deployed build `RALPH_BUILD_ID` names,\n * rather than as a standalone result. See `registerDeployment`. Defaults to\n * `RALPH_DEPLOY` being `1`.\n */\n deploy?: boolean;\n /**\n * Where the merged raw graph is written, laid out like the uploaded bundle.\n * Defaults to `ralph` inside the first project's output directory.\n */\n outputDir?: string;\n};\n\n/** One test's final attempt, as the reporter collected it. */\ntype Collected = {\n file: RawGraphFile;\n status: TestResult['status'];\n /** Screenshot and snapshot attachment paths or preserved bodies, by attachment name. */\n attachments: Map<string, string | Buffer>;\n};\n\n/**\n * Merges every recorded test's raw graph into one for the whole run, writes it\n * locally, and uploads it once when the mode is `upload`.\n *\n * Tests run in worker processes, so only a reporter, in the main process,\n * sees them all. For a sharded run, it runs in `playwright merge-reports` over\n * the shards' blob reports.\n */\nexport default class RalphReporter implements Reporter {\n private readonly startedAt = new Date().toISOString();\n private config?: FullConfig;\n\n /**\n * By test id, so a retry replaces the attempt before it. Read as each test\n * ends, and awaited in `onEnd`.\n */\n private readonly tests = new Map<string, Promise<Collected | undefined>>();\n\n constructor(private readonly options: RalphReporterOptions = {}) {\n // Before any worker starts, so the fixtures can tell the reporter is on.\n process.env[REPORTER_ENV] = '1';\n if (resolveMode(options) === 'upload') {\n // Throws, so missing credentials stop the run before any test.\n resolveUploadOptions(options);\n }\n }\n\n onBegin(config: FullConfig): void {\n this.config = config;\n }\n\n onTestEnd(test: TestCase, result: TestResult): void {\n const collected = collect(result);\n // Rejections are reported in `onEnd`; this only stops Node from calling\n // them unhandled in the meantime.\n collected.catch(() => undefined);\n this.tests.set(test.id, collected);\n }\n\n async onEnd(): Promise<{ status?: FullResult['status'] } | undefined> {\n try {\n const collected = (await Promise.all(this.tests.values())).filter(\n (item) => item !== undefined,\n );\n if (collected.length === 0) {\n return undefined;\n }\n\n const merged = this.merge(collected);\n const screenshots = await Promise.all(\n merged.images.map(\n async ({ source, ...image }): Promise<RawGraphScreenshot> => ({\n ...image,\n bytes: typeof source === 'string' ? await readFile(source) : source,\n }),\n ),\n );\n const snapshots = await Promise.all(\n merged.snapshots.map(\n async ({ nodeId, source }): Promise<RawGraphAriaSnapshot> => ({\n nodeId,\n bytes: typeof source === 'string' ? await readFile(source) : source,\n }),\n ),\n );\n const uploading =\n resolveMode(this.options) === 'upload' ||\n collected.some(({ file }) => file.mode === 'upload');\n\n // Before the local copy is written, so it names the same artifact as the\n // upload, and the link is printed as soon as it works.\n let prepared: PreparedUpload | undefined;\n if (uploading) {\n try {\n prepared = await this.reserve(merged.manifest);\n } catch (error) {\n const outputDir = await this.write(\n merged.manifest,\n screenshots,\n snapshots,\n );\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n }\n log(\n `Ralph: uploading the run. Follow the upload and processing at ${resultLink(prepared)}`,\n );\n }\n const manifest =\n prepared === undefined\n ? merged.manifest\n : { ...merged.manifest, artifactId: prepared.artifactId };\n const nodeCount = manifest.rawGraphs.reduce(\n (total, graph) => total + graph.nodes.length,\n 0,\n );\n const outputDir = await this.write(manifest, screenshots, snapshots);\n log(\n `Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`,\n );\n\n if (prepared !== undefined) {\n const receipt = await uploadRawGraph(\n manifest,\n screenshots,\n resolveUploadOptions(this.options),\n snapshots,\n ).catch((error: unknown) => {\n throw new Error(\n `${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`,\n { cause: error },\n );\n });\n await writeFile(\n path.join(outputDir, 'upload_receipt.json'),\n JSON.stringify(receipt, null, 2) + '\\n',\n );\n log(\n `Ralph: uploaded the run. Ralph is processing it at ${resultLink(receipt)}`,\n );\n }\n return undefined;\n } catch (error) {\n console.error(\n 'Ralph: ' + (error instanceof Error ? error.message : String(error)),\n );\n return { status: 'failed' };\n }\n }\n\n private reserve(manifest: RawGraphManifest): Promise<PreparedUpload> {\n const options = resolveUploadOptions(this.options);\n const deploy = this.options.deploy ?? process.env['RALPH_DEPLOY'] === '1';\n if (!deploy) {\n return prepareArtifactUpload(options);\n }\n\n if (manifest.buildId === undefined) {\n throw new Error(\n 'A deployment run uploads into its build, so set RALPH_BUILD_ID (or the buildId option) to the deployed build.',\n );\n }\n return registerDeployment(options, {\n buildId: manifest.buildId,\n config: manifest.config.config,\n });\n }\n\n printsToStdio(): boolean {\n return false;\n }\n\n /** Merges the tests into one manifest, with where to read their screenshots and snapshots. */\n private merge(collected: Collected[]): {\n manifest: RawGraphManifest;\n images: (Omit<RawGraphScreenshot, 'bytes'> & { source: string | Buffer })[];\n snapshots: { nodeId: string; source: string | Buffer }[];\n } {\n const [first] = collected;\n const same = (pick: (file: RawGraphFile) => unknown, what: string) => {\n const expected = JSON.stringify(pick(first!.file));\n if (\n collected.some(({ file }) => JSON.stringify(pick(file)) !== expected)\n ) {\n throw new Error(\n `Every recorded test in a run must share one ${what}; merge them per run.`,\n );\n }\n };\n same((file) => file.config, 'Ralph config');\n same((file) => [file.runId, file.buildId], 'runId and buildId');\n\n const images: (Omit<RawGraphScreenshot, 'bytes'> & {\n source: string | Buffer;\n })[] = [];\n const snapshots: { nodeId: string; source: string | Buffer }[] = [];\n const rawGraphs = collected\n .map(({ file, status, attachments }): RawGraph => {\n const nodes = file.rawGraph.nodes.map((node) => {\n if (node.status !== 'recorded') {\n return node;\n }\n const source = attachments.get(node.screenshot.attachmentName);\n if (source === undefined) {\n throw new Error(\n `The screenshot of node ${node.nodeId} is missing from the test results.`,\n );\n }\n images.push({\n nodeId: node.nodeId,\n contentType: node.screenshot.contentType,\n source,\n });\n // A snapshot is optional, so one missing from the results drops\n // from the node rather than failing the run.\n const snapshot =\n node.ariaSnapshot &&\n attachments.get(node.ariaSnapshot.attachmentName);\n if (snapshot !== undefined) {\n snapshots.push({ nodeId: node.nodeId, source: snapshot });\n }\n const { ariaSnapshot, ...rest } = node;\n return {\n ...rest,\n screenshot: {\n ...node.screenshot,\n path: bundleImagePath(node.nodeId, node.screenshot.contentType),\n },\n ...(ariaSnapshot &&\n snapshot !== undefined && {\n ariaSnapshot: {\n ...ariaSnapshot,\n path: bundleSnapshotPath(node.nodeId),\n },\n }),\n };\n });\n return {\n ...file.rawGraph,\n // The final status, which includes failures after the recorder\n // finished, such as a later fixture's teardown.\n status,\n nodes,\n complete: file.rawGraph.complete && status === 'passed',\n };\n })\n .sort(\n (a, b) =>\n a.project.localeCompare(b.project) ||\n a.titlePath.join('\\0').localeCompare(b.titlePath.join('\\0')) ||\n a.repeatEachIndex - b.repeatEachIndex,\n );\n\n const manifest: RawGraphManifest = {\n formatVersion: 1,\n producer: {\n name: '@ralphralphai/playwright',\n version: packageInfo.version,\n playwrightVersion: playwrightPackage.version,\n },\n runId: first!.file.runId,\n buildId: first!.file.buildId,\n // Replaced by the prepared artifact's id when the run is uploaded.\n artifactId: randomUUID(),\n shard: this.config?.shard ?? null,\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\n config: first!.file.config,\n rawGraphs,\n complete: rawGraphs.every((graph) => graph.complete),\n };\n\n return {\n manifest,\n images,\n snapshots,\n };\n }\n\n /** Writes the manifest, screenshots and snapshots in the bundle's layout. */\n private async write(\n manifest: RawGraphManifest,\n screenshots: RawGraphScreenshot[],\n snapshots: RawGraphAriaSnapshot[],\n ): Promise<string> {\n const outputDir = path.resolve(\n this.options.outputDir ??\n path.join(\n this.config?.projects[0]?.outputDir ??\n path.join(this.config?.rootDir ?? process.cwd(), 'test-results'),\n 'ralph',\n ),\n );\n await rm(outputDir, { recursive: true, force: true });\n\n const index: RawGraphBundleIndex = {\n manifest,\n screenshots: [],\n snapshots: [],\n };\n for (const { nodeId, contentType, bytes } of screenshots) {\n const imagePath = bundleImagePath(nodeId, contentType);\n index.screenshots.push({ nodeId, path: imagePath });\n await mkdir(path.dirname(path.join(outputDir, imagePath)), {\n recursive: true,\n });\n await writeFile(path.join(outputDir, imagePath), bytes);\n }\n for (const { nodeId, bytes } of snapshots) {\n const snapshotPath = bundleSnapshotPath(nodeId);\n index.snapshots.push({ nodeId, path: snapshotPath });\n await mkdir(path.dirname(path.join(outputDir, snapshotPath)), {\n recursive: true,\n });\n await writeFile(path.join(outputDir, snapshotPath), bytes);\n }\n await mkdir(outputDir, { recursive: true });\n await writeFile(\n path.join(outputDir, RAW_GRAPH_BUNDLE_INDEX),\n JSON.stringify(index, null, 2) + '\\n',\n );\n return outputDir;\n }\n}\n\n/** Reads a test's raw graph from its attachments; undefined if it recorded nothing. */\nasync function collect(result: TestResult): Promise<Collected | undefined> {\n const manifest = result.attachments.find(\n (item) => item.name === MANIFEST_ATTACHMENT,\n );\n if (!manifest || (!manifest.body && !manifest.path)) {\n return undefined;\n }\n const file = JSON.parse(\n manifest.body?.toString('utf8') ?? (await readFile(manifest.path!, 'utf8')),\n ) as RawGraphFile;\n const attachments = new Map<string, string | Buffer>();\n for (const { name, path: attachmentPath, body } of result.attachments) {\n const source = body ?? attachmentPath;\n if (source !== undefined) {\n attachments.set(name, source);\n }\n }\n return { file, status: result.status, attachments };\n}\n\nfunction log(message: string): void {\n process.stdout.write(message + '\\n');\n}\n"],"mappings":"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"}
|