@ralphralphai/playwright 1.8.0 → 2.0.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 +91 -55
- package/dist/index.cjs +1 -1
- package/dist/index.d.mts +19 -2
- package/dist/index.mjs +1 -1
- package/dist/reporter.cjs +47 -12
- package/dist/reporter.cjs.map +1 -1
- package/dist/reporter.d.mts +9 -2
- package/dist/reporter.mjs +47 -12
- package/dist/reporter.mjs.map +1 -1
- package/dist/{upload-D5hePvmA.mjs → upload--pBJ67uZ.mjs} +255 -16
- package/dist/upload--pBJ67uZ.mjs.map +1 -0
- package/dist/{upload-BvAi7mMY.cjs → upload-BGYOWfj1.cjs} +265 -14
- package/dist/upload-BGYOWfj1.cjs.map +1 -0
- package/package.json +3 -3
- package/dist/upload-BvAi7mMY.cjs.map +0 -1
- package/dist/upload-D5hePvmA.mjs.map +0 -1
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--pBJ67uZ.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
|
-
prepared = await
|
|
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");
|
|
@@ -74,10 +78,19 @@ var RalphReporter = class {
|
|
|
74
78
|
return { status: "failed" };
|
|
75
79
|
}
|
|
76
80
|
}
|
|
81
|
+
reserve(manifest) {
|
|
82
|
+
const options = resolveUploadOptions(this.options);
|
|
83
|
+
if (!(this.options.deploy ?? process.env["RALPH_DEPLOY"] === "1")) return prepareArtifactUpload(options);
|
|
84
|
+
if (manifest.buildId === void 0) throw new Error("A deployment run uploads into its build, so set RALPH_BUILD_ID (or the buildId option) to the deployed build.");
|
|
85
|
+
return registerDeployment(options, {
|
|
86
|
+
buildId: manifest.buildId,
|
|
87
|
+
config: manifest.config.config
|
|
88
|
+
});
|
|
89
|
+
}
|
|
77
90
|
printsToStdio() {
|
|
78
91
|
return false;
|
|
79
92
|
}
|
|
80
|
-
/** 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. */
|
|
81
94
|
merge(collected) {
|
|
82
95
|
const [first] = collected;
|
|
83
96
|
const same = (pick, what) => {
|
|
@@ -87,6 +100,7 @@ var RalphReporter = class {
|
|
|
87
100
|
same((file) => file.config, "Ralph config");
|
|
88
101
|
same((file) => [file.runId, file.buildId], "runId and buildId");
|
|
89
102
|
const images = [];
|
|
103
|
+
const snapshots = [];
|
|
90
104
|
const rawGraphs = collected.map(({ file, status, attachments }) => {
|
|
91
105
|
const nodes = file.rawGraph.nodes.map((node) => {
|
|
92
106
|
if (node.status !== "recorded") return node;
|
|
@@ -97,12 +111,22 @@ var RalphReporter = class {
|
|
|
97
111
|
contentType: node.screenshot.contentType,
|
|
98
112
|
source
|
|
99
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;
|
|
100
120
|
return {
|
|
101
|
-
...
|
|
121
|
+
...rest,
|
|
102
122
|
screenshot: {
|
|
103
123
|
...node.screenshot,
|
|
104
124
|
path: bundleImagePath(node.nodeId, node.screenshot.contentType)
|
|
105
|
-
}
|
|
125
|
+
},
|
|
126
|
+
...ariaSnapshot && snapshot !== void 0 && { ariaSnapshot: {
|
|
127
|
+
...ariaSnapshot,
|
|
128
|
+
path: bundleSnapshotPath(node.nodeId)
|
|
129
|
+
} }
|
|
106
130
|
};
|
|
107
131
|
});
|
|
108
132
|
return {
|
|
@@ -130,11 +154,12 @@ var RalphReporter = class {
|
|
|
130
154
|
rawGraphs,
|
|
131
155
|
complete: rawGraphs.every((graph) => graph.complete)
|
|
132
156
|
},
|
|
133
|
-
images
|
|
157
|
+
images,
|
|
158
|
+
snapshots
|
|
134
159
|
};
|
|
135
160
|
}
|
|
136
|
-
/** Writes the manifest and
|
|
137
|
-
async write(manifest, screenshots) {
|
|
161
|
+
/** Writes the manifest, screenshots and snapshots in the bundle's layout. */
|
|
162
|
+
async write(manifest, screenshots, snapshots) {
|
|
138
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"));
|
|
139
164
|
await rm(outputDir, {
|
|
140
165
|
recursive: true,
|
|
@@ -142,7 +167,8 @@ var RalphReporter = class {
|
|
|
142
167
|
});
|
|
143
168
|
const index = {
|
|
144
169
|
manifest,
|
|
145
|
-
screenshots: []
|
|
170
|
+
screenshots: [],
|
|
171
|
+
snapshots: []
|
|
146
172
|
};
|
|
147
173
|
for (const { nodeId, contentType, bytes } of screenshots) {
|
|
148
174
|
const imagePath = bundleImagePath(nodeId, contentType);
|
|
@@ -153,6 +179,15 @@ var RalphReporter = class {
|
|
|
153
179
|
await mkdir(path.dirname(path.join(outputDir, imagePath)), { recursive: true });
|
|
154
180
|
await writeFile(path.join(outputDir, imagePath), bytes);
|
|
155
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
|
+
}
|
|
156
191
|
await mkdir(outputDir, { recursive: true });
|
|
157
192
|
await writeFile(path.join(outputDir, RAW_GRAPH_BUNDLE_INDEX), JSON.stringify(index, null, 2) + "\n");
|
|
158
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 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 * 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 prepareArtifactUpload(\n resolveUploadOptions(this.options),\n );\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 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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"}
|
|
@@ -5,7 +5,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import configPackage from "@ralphralphai/config/package.json" with { type: "json" };
|
|
6
6
|
import { WebRalphConfigSchema } from "@ralphralphai/config/zod";
|
|
7
7
|
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
10
10
|
import playwrightPackage from "@playwright/test/package.json" with { type: "json" };
|
|
11
11
|
import { Readable } from "node:stream";
|
|
@@ -45,7 +45,7 @@ function snapshot(value) {
|
|
|
45
45
|
}
|
|
46
46
|
//#endregion
|
|
47
47
|
//#region package.json
|
|
48
|
-
var version = "
|
|
48
|
+
var version = "2.0.0";
|
|
49
49
|
//#endregion
|
|
50
50
|
//#region src/actions.ts
|
|
51
51
|
const BINDING = "__ralphAction";
|
|
@@ -522,6 +522,192 @@ async function waitForAnimations(page, timeoutMs) {
|
|
|
522
522
|
if (!(error instanceof errors.TimeoutError)) throw error;
|
|
523
523
|
}
|
|
524
524
|
}
|
|
525
|
+
const MAX_ARIA_DEPTH = Math.floor(128 / 2) - 2;
|
|
526
|
+
const STYLE_PROPERTIES = [
|
|
527
|
+
"font-family",
|
|
528
|
+
"font-size",
|
|
529
|
+
"font-weight",
|
|
530
|
+
"font-style",
|
|
531
|
+
"color",
|
|
532
|
+
"background-color"
|
|
533
|
+
];
|
|
534
|
+
const TRANSPARENT = "rgba(0, 0, 0, 0)";
|
|
535
|
+
const ELEMENT_LOOKUP_TIMEOUT_MS = 2e3;
|
|
536
|
+
/**
|
|
537
|
+
* The page's aria tree as JSON, with each element's box in document
|
|
538
|
+
* coordinates, its distinctive styles, and a hash of its image. Undefined when
|
|
539
|
+
* the snapshot is past the server's limits.
|
|
540
|
+
*
|
|
541
|
+
* The page is not scrolled first, because scrolling changes the page the
|
|
542
|
+
* screenshot just showed: sticky headers, lazy loading, scroll-triggered
|
|
543
|
+
* animation.
|
|
544
|
+
*/
|
|
545
|
+
async function captureAriaSnapshot(page, { viewport, scroll }) {
|
|
546
|
+
const snapshot = await page.locator("body").ariaSnapshotJSON({
|
|
547
|
+
mode: "ai",
|
|
548
|
+
boxes: true,
|
|
549
|
+
depth: MAX_ARIA_DEPTH
|
|
550
|
+
});
|
|
551
|
+
const visible = (await Promise.all(snapshot.map((node) => toDocument(page, node, scroll)))).flatMap((node) => {
|
|
552
|
+
const kept = keepShown(node, viewport.width);
|
|
553
|
+
return kept === void 0 ? [] : [kept];
|
|
554
|
+
});
|
|
555
|
+
const described = await Promise.all(visible.map((node) => describe(page, node, {})));
|
|
556
|
+
const json = Buffer.from(JSON.stringify(fillMissingRefs(described)));
|
|
557
|
+
return json.byteLength > 4194304 ? void 0 : json;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Moves the viewport boxes Playwright reports into the full-page screenshot's
|
|
561
|
+
* space. This has to happen before `keepShown`, which in viewport space would
|
|
562
|
+
* drop everything scrolled above the viewport, and no later move brings those
|
|
563
|
+
* back.
|
|
564
|
+
*
|
|
565
|
+
* An iframe's descendants are boxed in its own viewport, so they move by
|
|
566
|
+
* where that viewport sits: the iframe's box plus its border and padding.
|
|
567
|
+
*/
|
|
568
|
+
async function toDocument(page, node, offset) {
|
|
569
|
+
const box = node.box && {
|
|
570
|
+
...node.box,
|
|
571
|
+
x: node.box.x + offset.x,
|
|
572
|
+
y: node.box.y + offset.y
|
|
573
|
+
};
|
|
574
|
+
let childOffset = offset;
|
|
575
|
+
if (node.role === "iframe" && box !== void 0) {
|
|
576
|
+
const inset = (node.ref === void 0 ? void 0 : await readContentInset(page, node.ref)) ?? {
|
|
577
|
+
x: 0,
|
|
578
|
+
y: 0
|
|
579
|
+
};
|
|
580
|
+
childOffset = {
|
|
581
|
+
x: box.x + inset.x,
|
|
582
|
+
y: box.y + inset.y
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
...node,
|
|
587
|
+
...box && { box },
|
|
588
|
+
...node.children && { children: await Promise.all(node.children.map((child) => typeof child === "string" ? child : toDocument(page, child, childOffset))) }
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async function readContentInset(page, ref) {
|
|
592
|
+
try {
|
|
593
|
+
return await page.locator(`aria-ref=${ref}`).evaluate((element) => {
|
|
594
|
+
const computed = getComputedStyle(element);
|
|
595
|
+
return {
|
|
596
|
+
x: element.clientLeft + parseFloat(computed.paddingLeft),
|
|
597
|
+
y: element.clientTop + parseFloat(computed.paddingTop)
|
|
598
|
+
};
|
|
599
|
+
}, void 0, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
|
|
600
|
+
} catch {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* In document space, the only boxes no scroll reveals are those past the left
|
|
606
|
+
* or right of the viewport's width (a collapsed sidebar, an off-canvas menu)
|
|
607
|
+
* or above the top of the document.
|
|
608
|
+
*/
|
|
609
|
+
function isReachable(box, viewportWidth) {
|
|
610
|
+
if (box === void 0) return false;
|
|
611
|
+
return box.x < viewportWidth && box.x + box.width > 0 && box.y + box.height > 0;
|
|
612
|
+
}
|
|
613
|
+
function isShown(node, viewportWidth) {
|
|
614
|
+
return node.ref !== void 0 && isReachable(node.box, viewportWidth);
|
|
615
|
+
}
|
|
616
|
+
function keepShown(node, viewportWidth) {
|
|
617
|
+
const shown = isShown(node, viewportWidth);
|
|
618
|
+
const children = (node.children ?? []).flatMap((child) => {
|
|
619
|
+
if (typeof child === "string") return shown ? [child] : [];
|
|
620
|
+
const kept = keepShown(child, viewportWidth);
|
|
621
|
+
return kept === void 0 ? [] : [kept];
|
|
622
|
+
});
|
|
623
|
+
if (!shown && children.length === 0) return;
|
|
624
|
+
return node.children === void 0 ? node : {
|
|
625
|
+
...node,
|
|
626
|
+
children
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
function fillMissingRefs(nodes) {
|
|
630
|
+
let generated = 0;
|
|
631
|
+
const fill = (child) => {
|
|
632
|
+
if (typeof child === "string") return child;
|
|
633
|
+
generated += 1;
|
|
634
|
+
const ref = child.ref ?? `n${generated}`;
|
|
635
|
+
return child.children === void 0 ? {
|
|
636
|
+
...child,
|
|
637
|
+
ref
|
|
638
|
+
} : {
|
|
639
|
+
...child,
|
|
640
|
+
ref,
|
|
641
|
+
children: child.children.map(fill)
|
|
642
|
+
};
|
|
643
|
+
};
|
|
644
|
+
return nodes.map(fill);
|
|
645
|
+
}
|
|
646
|
+
async function readElement(page, ref, isImage) {
|
|
647
|
+
try {
|
|
648
|
+
return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage }) => {
|
|
649
|
+
const computed = getComputedStyle(element);
|
|
650
|
+
const style = Object.fromEntries(properties.map((property) => [property, computed.getPropertyValue(property)]));
|
|
651
|
+
if (!isImage) return { style };
|
|
652
|
+
if (element instanceof HTMLImageElement) return {
|
|
653
|
+
style,
|
|
654
|
+
image: { src: element.currentSrc || element.src }
|
|
655
|
+
};
|
|
656
|
+
if (element instanceof SVGElement) return {
|
|
657
|
+
style,
|
|
658
|
+
image: { markup: element.outerHTML }
|
|
659
|
+
};
|
|
660
|
+
const backgroundUrl = /url\("(.+)"\)/.exec(computed.backgroundImage)?.[1];
|
|
661
|
+
return backgroundUrl === void 0 ? { style } : {
|
|
662
|
+
style,
|
|
663
|
+
image: { src: new URL(backgroundUrl, document.baseURI).href }
|
|
664
|
+
};
|
|
665
|
+
}, {
|
|
666
|
+
properties: STYLE_PROPERTIES,
|
|
667
|
+
isImage
|
|
668
|
+
}, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
|
|
669
|
+
} catch {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function sha256(data) {
|
|
674
|
+
return createHash("sha256").update(data).digest("hex");
|
|
675
|
+
}
|
|
676
|
+
async function hashImage(page, image) {
|
|
677
|
+
if ("markup" in image) return sha256(image.markup);
|
|
678
|
+
if (image.src === "") return;
|
|
679
|
+
if (image.src.startsWith("data:")) return sha256(image.src);
|
|
680
|
+
try {
|
|
681
|
+
const response = await page.request.get(image.src);
|
|
682
|
+
try {
|
|
683
|
+
return response.ok() ? sha256(await response.body()) : void 0;
|
|
684
|
+
} finally {
|
|
685
|
+
await response.dispose();
|
|
686
|
+
}
|
|
687
|
+
} catch {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function distinctStyle(style, inherited) {
|
|
692
|
+
const distinct = Object.entries(style).filter(([property, value]) => {
|
|
693
|
+
if (property === "background-color" && value === TRANSPARENT) return false;
|
|
694
|
+
return inherited[property] !== value;
|
|
695
|
+
});
|
|
696
|
+
return distinct.length === 0 ? void 0 : Object.fromEntries(distinct);
|
|
697
|
+
}
|
|
698
|
+
async function describe(page, child, inherited) {
|
|
699
|
+
if (typeof child === "string") return child;
|
|
700
|
+
const facts = child.ref === void 0 ? void 0 : await readElement(page, child.ref, child.role === "img");
|
|
701
|
+
const style = facts === void 0 ? void 0 : distinctStyle(facts.style, inherited);
|
|
702
|
+
const imageHash = facts?.image === void 0 ? void 0 : await hashImage(page, facts.image);
|
|
703
|
+
const children = child.children === void 0 ? void 0 : await Promise.all(child.children.map((grandchild) => describe(page, grandchild, facts?.style ?? inherited)));
|
|
704
|
+
return {
|
|
705
|
+
...child,
|
|
706
|
+
...style && { style },
|
|
707
|
+
...imageHash && { imageHash },
|
|
708
|
+
...children && { children }
|
|
709
|
+
};
|
|
710
|
+
}
|
|
525
711
|
//#endregion
|
|
526
712
|
//#region src/conditions.ts
|
|
527
713
|
const declared = (declaration = {}) => {
|
|
@@ -701,6 +887,8 @@ const MANIFEST_ATTACHMENT = "ralph-raw-graph";
|
|
|
701
887
|
const REPORTER_ENV = "RALPH_PLAYWRIGHT_REPORTER";
|
|
702
888
|
/** Prefix of the attachment holding each recorded node's screenshot; the node id follows. */
|
|
703
889
|
const SCREENSHOT_ATTACHMENT_PREFIX = "ralph-screenshot-";
|
|
890
|
+
/** Prefix of the attachment holding each recorded node's aria snapshot; the node id follows. */
|
|
891
|
+
const ARIA_SNAPSHOT_ATTACHMENT_PREFIX = "ralph-aria-snapshot-";
|
|
704
892
|
/** Once per worker: a run without the tracker says so once, not per node. */
|
|
705
893
|
let warnedNoTracker = false;
|
|
706
894
|
const DEFAULT_SETTLE_MS = 150;
|
|
@@ -918,11 +1106,14 @@ var Recorder = class {
|
|
|
918
1106
|
const deadline = startDeadline(this.recordTimeoutMs, "Ralph recording timed out.");
|
|
919
1107
|
const { wait } = deadline;
|
|
920
1108
|
const navigatedAway = () => state.generation !== generation || page.url() !== request.actualUrl;
|
|
921
|
-
const
|
|
922
|
-
deadline.signal.throwIfAborted();
|
|
1109
|
+
const checkPage = () => {
|
|
923
1110
|
if (navigatedAway()) throw new Error("The page navigated before its recording completed.");
|
|
924
1111
|
if (page.isClosed()) throw new Error("The page closed before its recording completed.");
|
|
925
1112
|
};
|
|
1113
|
+
const check = () => {
|
|
1114
|
+
deadline.signal.throwIfAborted();
|
|
1115
|
+
checkPage();
|
|
1116
|
+
};
|
|
926
1117
|
try {
|
|
927
1118
|
check();
|
|
928
1119
|
if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
|
|
@@ -948,6 +1139,8 @@ var Recorder = class {
|
|
|
948
1139
|
check();
|
|
949
1140
|
const after = await wait(readLayout(page));
|
|
950
1141
|
check();
|
|
1142
|
+
const snapshot = await Promise.race([captureAriaSnapshot(page, after).catch(() => void 0), setTimeout$1(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })]);
|
|
1143
|
+
checkPage();
|
|
951
1144
|
const recorded = {
|
|
952
1145
|
...request,
|
|
953
1146
|
...withConditions(conditions),
|
|
@@ -957,7 +1150,8 @@ var Recorder = class {
|
|
|
957
1150
|
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
958
1151
|
layout: before,
|
|
959
1152
|
geometryStable: JSON.stringify(before) === JSON.stringify(after),
|
|
960
|
-
screenshot: await this.saveScreenshot(request.nodeId, image)
|
|
1153
|
+
screenshot: await this.saveScreenshot(request.nodeId, image),
|
|
1154
|
+
...snapshot && { ariaSnapshot: await this.saveAriaSnapshot(request.nodeId, snapshot) }
|
|
961
1155
|
};
|
|
962
1156
|
const retaken = sameNode ? this.nodes.findIndex(({ nodeId }) => nodeId === state.lastRecorded?.nodeId) : -1;
|
|
963
1157
|
if (retaken === -1) {
|
|
@@ -1017,6 +1211,21 @@ var Recorder = class {
|
|
|
1017
1211
|
contentType: image.contentType
|
|
1018
1212
|
};
|
|
1019
1213
|
}
|
|
1214
|
+
/** Writes the snapshot to the test output and attaches it to the report. */
|
|
1215
|
+
async saveAriaSnapshot(nodeId, snapshot) {
|
|
1216
|
+
const path = "ralph/" + nodeId + ".aria.json";
|
|
1217
|
+
const attachmentName = ARIA_SNAPSHOT_ATTACHMENT_PREFIX + nodeId;
|
|
1218
|
+
await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
|
|
1219
|
+
await writeFile(this.testInfo.outputPath(path), snapshot);
|
|
1220
|
+
await this.testInfo.attach(attachmentName, {
|
|
1221
|
+
...this.testInfo.config.preserveOutput === "always" ? { path: this.testInfo.outputPath(path) } : { body: snapshot },
|
|
1222
|
+
contentType: "application/json"
|
|
1223
|
+
});
|
|
1224
|
+
return {
|
|
1225
|
+
path,
|
|
1226
|
+
attachmentName
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1020
1229
|
/** Gives each node the actions taken since the node it was reached from was requested. */
|
|
1021
1230
|
attachActions(nodes) {
|
|
1022
1231
|
const byId = new Map(nodes.map((item) => [item.nodeId, item]));
|
|
@@ -1151,11 +1360,17 @@ function resolveUploadOptions(options) {
|
|
|
1151
1360
|
}
|
|
1152
1361
|
const RAW_GRAPH_BUNDLE_INDEX = "raw_graph.json";
|
|
1153
1362
|
const RAW_GRAPH_BUNDLE_IMAGE_DIR = "assets/images";
|
|
1363
|
+
const RAW_GRAPH_BUNDLE_SNAPSHOT_DIR = "assets/snapshots";
|
|
1154
1364
|
/** Where a node's screenshot sits inside the bundle. */
|
|
1155
1365
|
function bundleImagePath(nodeId, contentType) {
|
|
1156
1366
|
if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
|
|
1157
1367
|
return `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`;
|
|
1158
1368
|
}
|
|
1369
|
+
/** Where a node's aria snapshot sits inside the bundle. */
|
|
1370
|
+
function bundleSnapshotPath(nodeId) {
|
|
1371
|
+
if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
|
|
1372
|
+
return `${RAW_GRAPH_BUNDLE_SNAPSHOT_DIR}/${nodeId}.json`;
|
|
1373
|
+
}
|
|
1159
1374
|
const TOO_BIG = "This run is too big to upload to Ralph. Record fewer pages or screen sizes, or split the tests across runs.";
|
|
1160
1375
|
var TooBigError = class extends Error {
|
|
1161
1376
|
constructor() {
|
|
@@ -1166,23 +1381,33 @@ var TooBigError = class extends Error {
|
|
|
1166
1381
|
* Packs the manifest and screenshots into a gzipped tar archive, produced as it
|
|
1167
1382
|
* is read so the compressed bundle is never held whole:
|
|
1168
1383
|
*
|
|
1169
|
-
* raw_graph.json
|
|
1384
|
+
* raw_graph.json { manifest, screenshots: [{ nodeId, path }], snapshots: [...] }
|
|
1170
1385
|
* assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
|
|
1386
|
+
* assets/snapshots/<id>.json the node's aria snapshot, when it has one
|
|
1171
1387
|
*
|
|
1172
1388
|
* Fails with a `TooBigError` once the compressed bytes pass the limit.
|
|
1173
1389
|
*/
|
|
1174
|
-
function rawGraphBundleStream(manifest, screenshots) {
|
|
1390
|
+
function rawGraphBundleStream(manifest, screenshots, snapshots) {
|
|
1175
1391
|
if (Buffer.byteLength(JSON.stringify(manifest)) > 16777216 || screenshots.length > 5e3 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 268435456) throw new TooBigError();
|
|
1176
1392
|
const images = screenshots.map(({ nodeId, contentType, bytes }) => ({
|
|
1177
1393
|
nodeId,
|
|
1178
1394
|
path: bundleImagePath(nodeId, contentType),
|
|
1179
1395
|
bytes
|
|
1180
1396
|
}));
|
|
1397
|
+
const files = [...images, ...snapshots.map(({ nodeId, bytes }) => ({
|
|
1398
|
+
nodeId,
|
|
1399
|
+
path: bundleSnapshotPath(nodeId),
|
|
1400
|
+
bytes
|
|
1401
|
+
}))];
|
|
1181
1402
|
const index = {
|
|
1182
1403
|
manifest,
|
|
1183
1404
|
screenshots: images.map(({ nodeId, path }) => ({
|
|
1184
1405
|
nodeId,
|
|
1185
1406
|
path
|
|
1407
|
+
})),
|
|
1408
|
+
snapshots: snapshots.map(({ nodeId }) => ({
|
|
1409
|
+
nodeId,
|
|
1410
|
+
path: bundleSnapshotPath(nodeId)
|
|
1186
1411
|
}))
|
|
1187
1412
|
};
|
|
1188
1413
|
const attrs = {
|
|
@@ -1196,7 +1421,7 @@ function rawGraphBundleStream(manifest, screenshots) {
|
|
|
1196
1421
|
name: RAW_GRAPH_BUNDLE_INDEX,
|
|
1197
1422
|
...attrs
|
|
1198
1423
|
}, JSON.stringify(index)).on("error", onEntryError);
|
|
1199
|
-
for (const { path, bytes } of
|
|
1424
|
+
for (const { path, bytes } of files) tar.entry({
|
|
1200
1425
|
name: path,
|
|
1201
1426
|
...attrs
|
|
1202
1427
|
}, Buffer.from(bytes)).on("error", onEntryError);
|
|
@@ -1213,9 +1438,9 @@ function rawGraphBundleStream(manifest, screenshots) {
|
|
|
1213
1438
|
})());
|
|
1214
1439
|
}
|
|
1215
1440
|
/** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */
|
|
1216
|
-
async function createRawGraphBundle(manifest, screenshots) {
|
|
1441
|
+
async function createRawGraphBundle(manifest, screenshots, snapshots = []) {
|
|
1217
1442
|
const chunks = [];
|
|
1218
|
-
for await (const chunk of rawGraphBundleStream(manifest, screenshots)) chunks.push(chunk);
|
|
1443
|
+
for await (const chunk of rawGraphBundleStream(manifest, screenshots, snapshots)) chunks.push(chunk);
|
|
1219
1444
|
return Buffer.concat(chunks);
|
|
1220
1445
|
}
|
|
1221
1446
|
function isLoopback(url) {
|
|
@@ -1260,10 +1485,24 @@ const PREPARE_TIMEOUT_MS = 3e4;
|
|
|
1260
1485
|
* run can be followed while it uploads and while Ralph processes it.
|
|
1261
1486
|
*/
|
|
1262
1487
|
async function prepareArtifactUpload(options) {
|
|
1263
|
-
|
|
1488
|
+
return reserve(options, "/api/upload/artifact/prepare", { externalAppId: options.appId });
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Records that `buildId` went live under `config`, and reserves the artifact
|
|
1492
|
+
* its run uploads into. The run then becomes that build's graph in Ralph,
|
|
1493
|
+
* where an artifact from `prepareArtifactUpload` stays a standalone result.
|
|
1494
|
+
*/
|
|
1495
|
+
async function registerDeployment(options, deployment) {
|
|
1496
|
+
return reserve(options, "/api/upload/deployment/register", {
|
|
1497
|
+
externalAppId: options.appId,
|
|
1498
|
+
...deployment
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
async function reserve(options, route, body) {
|
|
1502
|
+
const url = checkedApiUrl(options, route);
|
|
1264
1503
|
const reply = await request(url, {
|
|
1265
1504
|
headers: { "content-type": "application/json" },
|
|
1266
|
-
body: JSON.stringify(
|
|
1505
|
+
body: JSON.stringify(body)
|
|
1267
1506
|
}, options, PREPARE_TIMEOUT_MS);
|
|
1268
1507
|
if (reply["result"] !== "ok" || typeof reply["artifactId"] !== "string" || reply["resultUrl"] !== void 0 && typeof reply["resultUrl"] !== "string") throw unreadableReply(url);
|
|
1269
1508
|
return {
|
|
@@ -1276,7 +1515,7 @@ async function prepareArtifactUpload(options) {
|
|
|
1276
1515
|
* `prepareArtifactUpload` has to have reserved. Ralph processes it after this
|
|
1277
1516
|
* returns; the result page shows when it is ready.
|
|
1278
1517
|
*/
|
|
1279
|
-
async function uploadRawGraph(manifest, screenshots, options) {
|
|
1518
|
+
async function uploadRawGraph(manifest, screenshots, options, snapshots = []) {
|
|
1280
1519
|
const url = checkedApiUrl(options, `/api/upload/web/apps/${options.appId}/artifact/${encodeURIComponent(manifest.artifactId)}`);
|
|
1281
1520
|
const storage = process.env["RALPH_UPLOAD_STORAGE"] || "gcs";
|
|
1282
1521
|
if (storage !== "gcs" && storage !== "local") throw new Error(`RALPH_UPLOAD_STORAGE must be gcs or local, not ${storage}.`);
|
|
@@ -1285,7 +1524,7 @@ async function uploadRawGraph(manifest, screenshots, options) {
|
|
|
1285
1524
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("uploadTimeoutMs in the Ralph reporter options must be a positive number of milliseconds.");
|
|
1286
1525
|
const receipt = await request(url, {
|
|
1287
1526
|
headers: { "content-type": "application/gzip" },
|
|
1288
|
-
body: Readable.toWeb(rawGraphBundleStream(manifest, screenshots)),
|
|
1527
|
+
body: Readable.toWeb(rawGraphBundleStream(manifest, screenshots, snapshots)),
|
|
1289
1528
|
duplex: "half"
|
|
1290
1529
|
}, options, timeoutMs);
|
|
1291
1530
|
if (receipt["result"] !== "ok" || receipt["status"] !== "queued" || receipt["artifactId"] !== manifest.artifactId || receipt["resultUrl"] !== void 0 && typeof receipt["resultUrl"] !== "string" || typeof receipt["replayed"] !== "boolean") throw unreadableReply(url);
|
|
@@ -1329,6 +1568,6 @@ async function responseDetail(response) {
|
|
|
1329
1568
|
return text.length > 200 ? text.slice(0, 200) + "…" : text || void 0;
|
|
1330
1569
|
}
|
|
1331
1570
|
//#endregion
|
|
1332
|
-
export {
|
|
1571
|
+
export { prepareArtifactUpload as a, resultLink as c, REPORTER_ENV as d, Recorder as f, resolveMode as g, loadConfigSync as h, createRawGraphBundle as i, uploadRawGraph as l, loadConfig as m, bundleImagePath as n, registerDeployment as o, version as p, bundleSnapshotPath as r, resolveUploadOptions as s, RAW_GRAPH_BUNDLE_INDEX as t, MANIFEST_ATTACHMENT as u };
|
|
1333
1572
|
|
|
1334
|
-
//# sourceMappingURL=upload
|
|
1573
|
+
//# sourceMappingURL=upload--pBJ67uZ.mjs.map
|