@ralphralphai/playwright 1.1.0 → 1.3.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 +44 -29
- package/dist/index.cjs +6 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +56 -10
- package/dist/index.mjs +6 -2
- package/dist/index.mjs.map +1 -1
- package/dist/reporter.cjs +24 -8
- package/dist/reporter.cjs.map +1 -1
- package/dist/reporter.d.mts +1 -2
- package/dist/reporter.mjs +24 -8
- package/dist/reporter.mjs.map +1 -1
- package/dist/{upload-giSVxpun.mjs → upload-Bd6rKvUx.mjs} +245 -36
- package/dist/upload-Bd6rKvUx.mjs.map +1 -0
- package/dist/{upload-QZreR5i6.cjs → upload-BzqsXl26.cjs} +255 -34
- package/dist/upload-BzqsXl26.cjs.map +1 -0
- package/package.json +2 -2
- package/dist/upload-QZreR5i6.cjs.map +0 -1
- package/dist/upload-giSVxpun.mjs.map +0 -1
package/dist/reporter.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as resolveUploadOptions, c as MANIFEST_ATTACHMENT, d as version, i as prepareArtifactUpload, l as REPORTER_ENV, m as resolveMode, n as bundleImagePath, o as resultLink, s as uploadRawGraph, t as RAW_GRAPH_BUNDLE_INDEX } from "./upload-Bd6rKvUx.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";
|
|
@@ -14,7 +14,6 @@ import playwrightPackage from "@playwright/test/package.json" with { type: "json
|
|
|
14
14
|
*/
|
|
15
15
|
var RalphReporter = class {
|
|
16
16
|
options;
|
|
17
|
-
artifactId = randomUUID();
|
|
18
17
|
startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
19
18
|
config;
|
|
20
19
|
/**
|
|
@@ -39,18 +38,35 @@ var RalphReporter = class {
|
|
|
39
38
|
try {
|
|
40
39
|
const collected = (await Promise.all(this.tests.values())).filter((item) => item !== void 0);
|
|
41
40
|
if (collected.length === 0) return;
|
|
42
|
-
const
|
|
43
|
-
const screenshots = await Promise.all(images.map(async ({ source, ...image }) => ({
|
|
41
|
+
const merged = this.merge(collected);
|
|
42
|
+
const screenshots = await Promise.all(merged.images.map(async ({ source, ...image }) => ({
|
|
44
43
|
...image,
|
|
45
44
|
bytes: typeof source === "string" ? await readFile(source) : source
|
|
46
45
|
})));
|
|
46
|
+
const uploading = resolveMode(this.options) === "upload" || collected.some(({ file }) => file.mode === "upload");
|
|
47
|
+
let prepared;
|
|
48
|
+
if (uploading) {
|
|
49
|
+
try {
|
|
50
|
+
prepared = await prepareArtifactUpload(resolveUploadOptions(this.options));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const outputDir = await this.write(merged.manifest, screenshots);
|
|
53
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
|
|
54
|
+
}
|
|
55
|
+
log(`Ralph: uploading the run. Follow the upload and processing at ${resultLink(prepared)}`);
|
|
56
|
+
}
|
|
57
|
+
const manifest = prepared === void 0 ? merged.manifest : {
|
|
58
|
+
...merged.manifest,
|
|
59
|
+
artifactId: prepared.artifactId
|
|
60
|
+
};
|
|
47
61
|
const nodeCount = manifest.rawGraphs.reduce((total, graph) => total + graph.nodes.length, 0);
|
|
48
62
|
const outputDir = await this.write(manifest, screenshots);
|
|
49
63
|
log(`Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`);
|
|
50
|
-
if (
|
|
51
|
-
const receipt = await uploadRawGraph(manifest, screenshots, resolveUploadOptions(this.options))
|
|
64
|
+
if (prepared !== void 0) {
|
|
65
|
+
const receipt = await uploadRawGraph(manifest, screenshots, resolveUploadOptions(this.options)).catch((error) => {
|
|
66
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
|
|
67
|
+
});
|
|
52
68
|
await writeFile(path.join(outputDir, "upload_receipt.json"), JSON.stringify(receipt, null, 2) + "\n");
|
|
53
|
-
log(`Ralph: uploaded the run
|
|
69
|
+
log(`Ralph: uploaded the run. Ralph is processing it at ${resultLink(receipt)}`);
|
|
54
70
|
}
|
|
55
71
|
return;
|
|
56
72
|
} catch (error) {
|
|
@@ -106,7 +122,7 @@ var RalphReporter = class {
|
|
|
106
122
|
},
|
|
107
123
|
runId: first.file.runId,
|
|
108
124
|
buildId: first.file.buildId,
|
|
109
|
-
artifactId:
|
|
125
|
+
artifactId: randomUUID(),
|
|
110
126
|
shard: this.config?.shard ?? null,
|
|
111
127
|
startedAt: this.startedAt,
|
|
112
128
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
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 resolveUploadOptions,\n bundleImagePath,\n uploadRawGraph,\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`. */\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 artifactId = randomUUID();\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 { manifest, images } = this.merge(collected);\n const screenshots = await Promise.all(\n images.map(\n async ({ source, ...image }): Promise<RawGraphScreenshot> => ({\n ...image,\n bytes: typeof source === 'string' ? await readFile(source) : source,\n }),\n ),\n );\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 (\n resolveMode(this.options) === 'upload' ||\n collected.some(({ file }) => file.mode === 'upload')\n ) {\n const receipt = await uploadRawGraph(\n manifest,\n screenshots,\n resolveUploadOptions(this.options),\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's raw graph. ${receipt.resultUrl ?? 'Result id: ' + receipt.resultId}`,\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 artifactId: this.artifactId,\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":";;;;;;;;;;;;;;AA8DA,IAAqB,gBAArB,MAAuD;CAWxB;CAV7B,aAA8B,WAAW;CACzC,6BAA6B,IAAI,KAAK,EAAA,CAAE,YAAY;CACpD;;;;;CAMA,wBAAyB,IAAI,IAA4C;CAEzE,YAAY,UAAiD,CAAC,GAAG;EAApC,KAAA,UAAA;EAE3B,QAAQ,IAAI,gBAAgB;EAC5B,IAAI,YAAY,OAAO,MAAM,UAE3B,qBAAqB,OAAO;CAEhC;CAEA,QAAQ,QAA0B;EAChC,KAAK,SAAS;CAChB;CAEA,UAAU,MAAgB,QAA0B;EAClD,MAAM,YAAY,QAAQ,MAAM;EAGhC,UAAU,YAAY,KAAA,CAAS;EAC/B,KAAK,MAAM,IAAI,KAAK,IAAI,SAAS;CACnC;CAEA,MAAM,QAAgE;EACpE,IAAI;GACF,MAAM,aAAa,MAAM,QAAQ,IAAI,KAAK,MAAM,OAAO,CAAC,EAAA,CAAG,QACxD,SAAS,SAAS,KAAA,CACrB;GACA,IAAI,UAAU,WAAW,GACvB;GAGF,MAAM,EAAE,UAAU,WAAW,KAAK,MAAM,SAAS;GACjD,MAAM,cAAc,MAAM,QAAQ,IAChC,OAAO,IACL,OAAO,EAAE,QAAQ,GAAG,aAA0C;IAC5D,GAAG;IACH,OAAO,OAAO,WAAW,WAAW,MAAM,SAAS,MAAM,IAAI;GAC/D,EACF,CACF;GACA,MAAM,YAAY,SAAS,UAAU,QAClC,OAAO,UAAU,QAAQ,MAAM,MAAM,QACtC,CACF;GACA,MAAM,YAAY,MAAM,KAAK,MAAM,UAAU,WAAW;GACxD,IACE,iBAAiB,SAAS,UAAU,OAAO,YAAY,UAAU,gBAAgB,WACnF;GAEA,IACE,YAAY,KAAK,OAAO,MAAM,YAC9B,UAAU,MAAM,EAAE,WAAW,KAAK,SAAS,QAAQ,GACnD;IACA,MAAM,UAAU,MAAM,eACpB,UACA,aACA,qBAAqB,KAAK,OAAO,CACnC;IACA,MAAM,UACJ,KAAK,KAAK,WAAW,qBAAqB,GAC1C,KAAK,UAAU,SAAS,MAAM,CAAC,IAAI,IACrC;IACA,IACE,wCAAwC,QAAQ,aAAa,gBAAgB,QAAQ,UACvF;GACF;GACA;EACF,SAAS,OAAO;GACd,QAAQ,MACN,aAAa,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EACpE;GACA,OAAO,EAAE,QAAQ,SAAS;EAC5B;CACF;CAEA,gBAAyB;EACvB,OAAO;CACT;;CAGA,MAAc,WAGZ;EACA,MAAM,CAAC,SAAS;EAChB,MAAM,QAAQ,MAAuC,SAAiB;GACpE,MAAM,WAAW,KAAK,UAAU,KAAK,MAAO,IAAI,CAAC;GACjD,IACE,UAAU,MAAM,EAAE,WAAW,KAAK,UAAU,KAAK,IAAI,CAAC,MAAM,QAAQ,GAEpE,MAAM,IAAI,MACR,+CAA+C,KAAK,sBACtD;EAEJ;EACA,MAAM,SAAS,KAAK,QAAQ,cAAc;EAC1C,MAAM,SAAS,CAAC,KAAK,OAAO,KAAK,OAAO,GAAG,mBAAmB;EAE9D,MAAM,SAEC,CAAC;EACR,MAAM,YAAY,UACf,KAAK,EAAE,MAAM,QAAQ,kBAA4B;GAChD,MAAM,QAAQ,KAAK,SAAS,MAAM,KAAK,SAAS;IAC9C,IAAI,KAAK,WAAW,YAClB,OAAO;IAET,MAAM,SAAS,YAAY,IAAI,KAAK,WAAW,cAAc;IAC7D,IAAI,WAAW,KAAA,GACb,MAAM,IAAI,MACR,0BAA0B,KAAK,OAAO,mCACxC;IAEF,OAAO,KAAK;KACV,QAAQ,KAAK;KACb,aAAa,KAAK,WAAW;KAC7B;IACF,CAAC;IACD,OAAO;KACL,GAAG;KACH,YAAY;MACV,GAAG,KAAK;MACR,MAAM,gBAAgB,KAAK,QAAQ,KAAK,WAAW,WAAW;KAChE;IACF;GACF,CAAC;GACD,OAAO;IACL,GAAG,KAAK;IAGR;IACA;IACA,UAAU,KAAK,SAAS,YAAY,WAAW;GACjD;EACF,CAAC,CAAC,CACD,MACE,GAAG,MACF,EAAE,QAAQ,cAAc,EAAE,OAAO,KACjC,EAAE,UAAU,KAAK,IAAI,CAAC,CAAC,cAAc,EAAE,UAAU,KAAK,IAAI,CAAC,KAC3D,EAAE,kBAAkB,EAAE,eAC1B;EAoBF,OAAO;GACL,UAAA;IAlBA,eAAe;IACf,UAAU;KACR,MAAM;KACGA;KACT,mBAAmB,kBAAkB;IACvC;IACA,OAAO,MAAO,KAAK;IACnB,SAAS,MAAO,KAAK;IACrB,YAAY,KAAK;IACjB,OAAO,KAAK,QAAQ,SAAS;IAC7B,WAAW,KAAK;IAChB,6BAAY,IAAI,KAAK,EAAA,CAAE,YAAY;IACnC,QAAQ,MAAO,KAAK;IACpB;IACA,UAAU,UAAU,OAAO,UAAU,MAAM,QAAQ;GAI5C;GACP;EACF;CACF;;CAGA,MAAc,MACZ,UACA,aACiB;EACjB,MAAM,YAAY,KAAK,QACrB,KAAK,QAAQ,aACX,KAAK,KACH,KAAK,QAAQ,SAAS,EAAE,EAAE,aACxB,KAAK,KAAK,KAAK,QAAQ,WAAW,QAAQ,IAAI,GAAG,cAAc,GACjE,OACF,CACJ;EACA,MAAM,GAAG,WAAW;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;EAEpD,MAAM,QAA6B;GAAE;GAAU,aAAa,CAAC;EAAE;EAC/D,KAAK,MAAM,EAAE,QAAQ,aAAa,WAAW,aAAa;GACxD,MAAM,YAAY,gBAAgB,QAAQ,WAAW;GACrD,MAAM,YAAY,KAAK;IAAE;IAAQ,MAAM;GAAU,CAAC;GAClD,MAAM,MAAM,KAAK,QAAQ,KAAK,KAAK,WAAW,SAAS,CAAC,GAAG,EACzD,WAAW,KACb,CAAC;GACD,MAAM,UAAU,KAAK,KAAK,WAAW,SAAS,GAAG,KAAK;EACxD;EACA,MAAM,MAAM,WAAW,EAAE,WAAW,KAAK,CAAC;EAC1C,MAAM,UACJ,KAAK,KAAK,WAAW,sBAAsB,GAC3C,KAAK,UAAU,OAAO,MAAM,CAAC,IAAI,IACnC;EACA,OAAO;CACT;AACF;;AAGA,eAAe,QAAQ,QAAoD;CACzE,MAAM,WAAW,OAAO,YAAY,MACjC,SAAS,KAAK,SAAS,mBAC1B;CACA,IAAI,CAAC,YAAa,CAAC,SAAS,QAAQ,CAAC,SAAS,MAC5C;CAEF,MAAM,OAAO,KAAK,MAChB,SAAS,MAAM,SAAS,MAAM,KAAM,MAAM,SAAS,SAAS,MAAO,MAAM,CAC3E;CACA,MAAM,8BAAc,IAAI,IAA6B;CACrD,KAAK,MAAM,EAAE,MAAM,MAAM,gBAAgB,UAAU,OAAO,aAAa;EACrE,MAAM,SAAS,QAAQ;EACvB,IAAI,WAAW,KAAA,GACb,YAAY,IAAI,MAAM,MAAM;CAEhC;CACA,OAAO;EAAE;EAAM,QAAQ,OAAO;EAAQ;CAAY;AACpD;AAEA,SAAS,IAAI,SAAuB;CAClC,QAAQ,OAAO,MAAM,UAAU,IAAI;AACrC"}
|
|
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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"}
|
|
@@ -8,8 +8,9 @@ import { parse, printParseErrorCode } from "jsonc-parser";
|
|
|
8
8
|
import { 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
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { Readable } from "node:stream";
|
|
12
|
+
import { pipeline } from "node:stream/promises";
|
|
13
|
+
import { createGzip } from "node:zlib";
|
|
13
14
|
import { pack } from "tar-stream";
|
|
14
15
|
//#region src/config.ts
|
|
15
16
|
function resolveMode(options) {
|
|
@@ -44,7 +45,7 @@ function snapshot(value) {
|
|
|
44
45
|
}
|
|
45
46
|
//#endregion
|
|
46
47
|
//#region package.json
|
|
47
|
-
var version = "1.
|
|
48
|
+
var version = "1.3.0";
|
|
48
49
|
//#endregion
|
|
49
50
|
//#region src/animations.ts
|
|
50
51
|
/**
|
|
@@ -66,6 +67,57 @@ async function waitForAnimations(page, timeoutMs) {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
//#endregion
|
|
70
|
+
//#region src/conditions.ts
|
|
71
|
+
const declared = (declaration = {}) => {
|
|
72
|
+
const conditions = {};
|
|
73
|
+
for (const [key, value] of Object.entries(declaration)) {
|
|
74
|
+
if (value === void 0) continue;
|
|
75
|
+
conditions[key] = [...new Set((Array.isArray(value) ? value : [value]).map((item) => item ?? null))];
|
|
76
|
+
}
|
|
77
|
+
return conditions;
|
|
78
|
+
};
|
|
79
|
+
/** A declaration as sets: a scalar is a one-element set, `undefined` is `null`. */
|
|
80
|
+
function declaredConditions(declaration) {
|
|
81
|
+
return {
|
|
82
|
+
visitorTags: declared(declaration.visitorTags),
|
|
83
|
+
pageTags: declared(declaration.pageTags)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Later declarations win per key, as later `tagPage` calls do in the app. */
|
|
87
|
+
function mergeConditions(layers) {
|
|
88
|
+
return {
|
|
89
|
+
visitorTags: Object.assign({}, ...layers.map((layer) => layer.visitorTags)),
|
|
90
|
+
pageTags: Object.assign({}, ...layers.map((layer) => layer.pageTags))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Declared keys the app's tracker disagrees with, as `visitorTags.plan`. A
|
|
95
|
+
* node's conditions come from the test alone, so this is the only check that
|
|
96
|
+
* production events will carry the tags its conditions name: a key the app
|
|
97
|
+
* never sets, or sets under another name, leaves the node without traffic.
|
|
98
|
+
*/
|
|
99
|
+
function mismatchesOf(declared, reported) {
|
|
100
|
+
if (reported === void 0) return [];
|
|
101
|
+
const mismatches = [];
|
|
102
|
+
for (const kind of ["visitorTags", "pageTags"]) for (const [key, allowed] of Object.entries(declared[kind] ?? {})) if (!allowed.includes(reported[kind][key] ?? null)) mismatches.push(`${kind}.${key}`);
|
|
103
|
+
return mismatches.sort();
|
|
104
|
+
}
|
|
105
|
+
/** Absent kinds are left out, so an unconditional node has no conditions. */
|
|
106
|
+
function compactConditions(conditions) {
|
|
107
|
+
const compact = {};
|
|
108
|
+
for (const kind of ["visitorTags", "pageTags"]) if (Object.keys(conditions[kind] ?? {}).length > 0) compact[kind] = conditions[kind];
|
|
109
|
+
return Object.keys(compact).length === 0 ? void 0 : compact;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The node a state belongs to, as a string, whatever the order of keys or
|
|
113
|
+
* values. Equal keys are one node, so a tag change that leaves this alone
|
|
114
|
+
* records nothing.
|
|
115
|
+
*/
|
|
116
|
+
function nodeKey(conditions) {
|
|
117
|
+
const sorted = (tags = {}) => Object.entries(tags).map(([key, values]) => [key, values.map((value) => JSON.stringify(value)).sort()]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
118
|
+
return JSON.stringify([sorted(conditions.visitorTags), sorted(conditions.pageTags)]);
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
69
121
|
//#region src/image.ts
|
|
70
122
|
/**
|
|
71
123
|
* Matches the crawler's full-page screenshots: lossless measured larger than this
|
|
@@ -166,6 +218,8 @@ const MANIFEST_ATTACHMENT = "ralph-raw-graph";
|
|
|
166
218
|
const REPORTER_ENV = "RALPH_PLAYWRIGHT_REPORTER";
|
|
167
219
|
/** Prefix of the attachment holding each recorded node's screenshot; the node id follows. */
|
|
168
220
|
const SCREENSHOT_ATTACHMENT_PREFIX = "ralph-screenshot-";
|
|
221
|
+
/** Once per worker: a run without the tracker says so once, not per node. */
|
|
222
|
+
let warnedNoTracker = false;
|
|
169
223
|
const DEFAULT_SETTLE_MS = 150;
|
|
170
224
|
const DEFAULT_RECORD_TIMEOUT_MS = 5e3;
|
|
171
225
|
/** Where the raw graph and screenshots are written, inside Playwright's per-test output directory. */
|
|
@@ -236,6 +290,17 @@ var Recorder = class {
|
|
|
236
290
|
if (this.paused) return;
|
|
237
291
|
await this.enqueueCreateNodeRequest(state, "explicit", options);
|
|
238
292
|
}
|
|
293
|
+
async setNodeConditions(page, declaration) {
|
|
294
|
+
this.observe(page.context());
|
|
295
|
+
const state = this.pages.get(page);
|
|
296
|
+
if (!state) throw new Error("Cannot tag a closed browser page.");
|
|
297
|
+
const key = Symbol();
|
|
298
|
+
state.declared.set(key, declaredConditions(declaration));
|
|
299
|
+
await this.recordConditionChange(state);
|
|
300
|
+
return async () => {
|
|
301
|
+
if (state.declared.delete(key)) await this.recordConditionChange(state);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
239
304
|
/**
|
|
240
305
|
* Stops requesting nodes, automatic or explicit, until `resume`. Recordings
|
|
241
306
|
* already requested still complete. The next node after `resume` links to
|
|
@@ -272,6 +337,8 @@ var Recorder = class {
|
|
|
272
337
|
linkPreviousNodes(nodes, (pageId) => this.pageInfo(pageId)?.openerPageId, (nodeId) => this.requestedAtMs.get(nodeId) ?? 0, (pageId) => this.pageState(pageId)?.pauseAnchor);
|
|
273
338
|
const failures = nodes.filter((item) => item.status === "uncaptured" && item.reason === "error");
|
|
274
339
|
await this.writeRawGraph(this.buildRawGraph(nodes, failures.length));
|
|
340
|
+
const mismatched = nodes.filter((item) => item.tagMismatches?.length);
|
|
341
|
+
if (this.options.strictNodeTags && mismatched.length && this.testInfo.status === "passed") throw new Error("Ralph: the app reported tags outside what the test declared, on " + mismatched.map((item) => item.url + " (" + item.tagMismatches.join(", ") + ")").join("; ") + ".");
|
|
275
342
|
if (failures.length && this.testInfo.status === "passed") throw new Error("Ralph could not record " + failures.length + " page state(s). See the ralph-raw-graph attachment.");
|
|
276
343
|
}
|
|
277
344
|
watchPage(page) {
|
|
@@ -285,6 +352,7 @@ var Recorder = class {
|
|
|
285
352
|
lastUrl: "",
|
|
286
353
|
generation: 0,
|
|
287
354
|
sequence: 0,
|
|
355
|
+
declared: /* @__PURE__ */ new Map(),
|
|
288
356
|
tail: Promise.resolve(),
|
|
289
357
|
opener: Promise.resolve(),
|
|
290
358
|
onNavigation: (frame) => {
|
|
@@ -308,8 +376,18 @@ var Recorder = class {
|
|
|
308
376
|
if (url === state.lastUrl) return;
|
|
309
377
|
state.generation++;
|
|
310
378
|
state.lastUrl = url;
|
|
379
|
+
state.declared.clear();
|
|
311
380
|
if (!this.paused && isHttpUrl(url)) this.enqueueCreateNodeRequest(state, "url-change", {}).catch(() => void 0);
|
|
312
381
|
}
|
|
382
|
+
async recordConditionChange(state) {
|
|
383
|
+
if (!this.paused && isHttpUrl(state.page.url())) await this.enqueueCreateNodeRequest(state, "condition-change", {});
|
|
384
|
+
}
|
|
385
|
+
declaredConditions(state) {
|
|
386
|
+
return mergeConditions([...state.declared.values()]);
|
|
387
|
+
}
|
|
388
|
+
async reportedTags(page) {
|
|
389
|
+
return page.evaluate(() => window.__ralphTags?.()).catch(() => void 0);
|
|
390
|
+
}
|
|
313
391
|
/** Queues a recording behind the tab's earlier ones. */
|
|
314
392
|
enqueueCreateNodeRequest(state, trigger, options) {
|
|
315
393
|
const actualUrl = state.page.url();
|
|
@@ -348,7 +426,17 @@ var Recorder = class {
|
|
|
348
426
|
};
|
|
349
427
|
try {
|
|
350
428
|
check();
|
|
351
|
-
if (request.trigger
|
|
429
|
+
if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
|
|
430
|
+
check();
|
|
431
|
+
const conditions = this.declaredConditions(state);
|
|
432
|
+
const reported = await wait(this.reportedTags(page));
|
|
433
|
+
const mismatches = mismatchesOf(conditions, reported);
|
|
434
|
+
const node = nodeKey(conditions);
|
|
435
|
+
if (request.trigger === "condition-change" && state.lastRecorded?.url === request.url && state.lastRecorded.node === node) return;
|
|
436
|
+
if (reported === void 0 && compactConditions(conditions) !== void 0 && !warnedNoTracker) {
|
|
437
|
+
warnedNoTracker = true;
|
|
438
|
+
console.warn("Ralph: this page runs no tracker, so the node conditions the test declares cannot be checked against the tags the app reports. Production events must carry the same tags, or these nodes will match no visitors.");
|
|
439
|
+
}
|
|
352
440
|
if (this.options.reduceMotion) {
|
|
353
441
|
check();
|
|
354
442
|
await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));
|
|
@@ -362,20 +450,28 @@ var Recorder = class {
|
|
|
362
450
|
check();
|
|
363
451
|
this.nodes.push({
|
|
364
452
|
...request,
|
|
453
|
+
...withConditions(conditions),
|
|
454
|
+
...reported === void 0 ? {} : { reportedTags: reported },
|
|
455
|
+
...mismatches.length > 0 ? { tagMismatches: mismatches } : {},
|
|
365
456
|
status: "recorded",
|
|
366
457
|
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
367
458
|
layout: before,
|
|
368
459
|
geometryStable: JSON.stringify(before) === JSON.stringify(after),
|
|
369
460
|
screenshot: await this.saveScreenshot(request.nodeId, image)
|
|
370
461
|
});
|
|
462
|
+
state.lastRecorded = {
|
|
463
|
+
url: request.url,
|
|
464
|
+
node
|
|
465
|
+
};
|
|
371
466
|
} catch (error) {
|
|
372
467
|
this.nodes.push({
|
|
373
468
|
...request,
|
|
469
|
+
...withConditions(this.declaredConditions(state)),
|
|
374
470
|
status: "uncaptured",
|
|
375
471
|
reason: navigatedAway() ? "navigated-away" : "error",
|
|
376
472
|
message: error instanceof Error ? error.message : String(error)
|
|
377
473
|
});
|
|
378
|
-
if (request.trigger
|
|
474
|
+
if (request.trigger !== "url-change") throw error;
|
|
379
475
|
} finally {
|
|
380
476
|
deadline.cancel();
|
|
381
477
|
}
|
|
@@ -455,6 +551,10 @@ var Recorder = class {
|
|
|
455
551
|
});
|
|
456
552
|
}
|
|
457
553
|
};
|
|
554
|
+
function withConditions(conditions) {
|
|
555
|
+
const compact = compactConditions(conditions);
|
|
556
|
+
return compact === void 0 ? {} : { conditions: compact };
|
|
557
|
+
}
|
|
458
558
|
function isHttpUrl(url) {
|
|
459
559
|
return /^https?:/.test(url);
|
|
460
560
|
}
|
|
@@ -498,14 +598,17 @@ function linkPreviousNodes(nodes, openerOf, requestedAtMs, pauseAnchorOf) {
|
|
|
498
598
|
if (from) item.previousNodeId = from.nodeId;
|
|
499
599
|
});
|
|
500
600
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
601
|
+
/** Where the uploaded run opens: the server's link, else the Ralph dashboard. */
|
|
602
|
+
function resultLink({ artifactId, resultUrl }) {
|
|
603
|
+
return resultUrl ?? `https://dash.ralphralph.ai/uploads/${encodeURIComponent(artifactId)}`;
|
|
604
|
+
}
|
|
605
|
+
/** Where the reporter uploads: its options, else the environment, else Ralph's production server. */
|
|
504
606
|
function resolveUploadOptions(options) {
|
|
505
607
|
const uploadKey = process.env["RALPH_UPLOAD_KEY"];
|
|
506
|
-
const apiUrl = options.apiUrl
|
|
608
|
+
const apiUrl = options.apiUrl || process.env["RALPH_API_URL"] || "https://api.ralphralph.ai";
|
|
507
609
|
const appId = options.appId ?? process.env["RALPH_APP_ID"];
|
|
508
|
-
if (!uploadKey
|
|
610
|
+
if (!uploadKey) throw new Error(`To upload to Ralph, set RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`);
|
|
611
|
+
if (!appId) throw new Error("To upload to Ralph, set RALPH_APP_ID (or the reporter option appId) to the app the recordings belong to.");
|
|
509
612
|
return {
|
|
510
613
|
uploadKey,
|
|
511
614
|
apiUrl,
|
|
@@ -520,14 +623,23 @@ function bundleImagePath(nodeId, contentType) {
|
|
|
520
623
|
if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
|
|
521
624
|
return `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`;
|
|
522
625
|
}
|
|
626
|
+
const TOO_BIG = "This run is too big to upload to Ralph. Record fewer pages or screen sizes, or split the tests across runs.";
|
|
627
|
+
var TooBigError = class extends Error {
|
|
628
|
+
constructor() {
|
|
629
|
+
super(TOO_BIG);
|
|
630
|
+
}
|
|
631
|
+
};
|
|
523
632
|
/**
|
|
524
|
-
* Packs the manifest and screenshots into a gzipped tar archive
|
|
633
|
+
* Packs the manifest and screenshots into a gzipped tar archive, produced as it
|
|
634
|
+
* is read so the compressed bundle is never held whole:
|
|
525
635
|
*
|
|
526
636
|
* raw_graph.json { manifest, screenshots: [{ nodeId, path }] }
|
|
527
637
|
* assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
|
|
638
|
+
*
|
|
639
|
+
* Fails with a `TooBigError` once the compressed bytes pass the limit.
|
|
528
640
|
*/
|
|
529
|
-
|
|
530
|
-
if (Buffer.byteLength(JSON.stringify(manifest)) > 16777216 || screenshots.length > 5e3 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 268435456) throw new
|
|
641
|
+
function rawGraphBundleStream(manifest, screenshots) {
|
|
642
|
+
if (Buffer.byteLength(JSON.stringify(manifest)) > 16777216 || screenshots.length > 5e3 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 268435456) throw new TooBigError();
|
|
531
643
|
const images = screenshots.map(({ nodeId, contentType, bytes }) => ({
|
|
532
644
|
nodeId,
|
|
533
645
|
path: bundleImagePath(nodeId, contentType),
|
|
@@ -546,47 +658,144 @@ async function createRawGraphBundle(manifest, screenshots) {
|
|
|
546
658
|
gid: 0
|
|
547
659
|
};
|
|
548
660
|
const tar = pack();
|
|
661
|
+
const onEntryError = (error) => tar.destroy(error);
|
|
549
662
|
tar.entry({
|
|
550
663
|
name: RAW_GRAPH_BUNDLE_INDEX,
|
|
551
664
|
...attrs
|
|
552
|
-
}, JSON.stringify(index));
|
|
665
|
+
}, JSON.stringify(index)).on("error", onEntryError);
|
|
553
666
|
for (const { path, bytes } of images) tar.entry({
|
|
554
667
|
name: path,
|
|
555
668
|
...attrs
|
|
556
|
-
}, Buffer.from(bytes));
|
|
669
|
+
}, Buffer.from(bytes)).on("error", onEntryError);
|
|
557
670
|
tar.finalize();
|
|
671
|
+
const gzip = createGzip();
|
|
672
|
+
pipeline(tar, gzip).catch((error) => gzip.destroy(error));
|
|
673
|
+
return Readable.from((async function* () {
|
|
674
|
+
let size = 0;
|
|
675
|
+
for await (const chunk of gzip) {
|
|
676
|
+
size += chunk.byteLength;
|
|
677
|
+
if (size > 268435456) throw new TooBigError();
|
|
678
|
+
yield chunk;
|
|
679
|
+
}
|
|
680
|
+
})());
|
|
681
|
+
}
|
|
682
|
+
/** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */
|
|
683
|
+
async function createRawGraphBundle(manifest, screenshots) {
|
|
558
684
|
const chunks = [];
|
|
559
|
-
for await (const chunk of
|
|
560
|
-
|
|
561
|
-
if (compressed.byteLength > 268435456) throw new Error("Ralph compressed raw graph bundle exceeds upload limits.");
|
|
562
|
-
return compressed;
|
|
685
|
+
for await (const chunk of rawGraphBundleStream(manifest, screenshots)) chunks.push(chunk);
|
|
686
|
+
return Buffer.concat(chunks);
|
|
563
687
|
}
|
|
564
688
|
function isLoopback(url) {
|
|
565
689
|
return url.hostname === "localhost" || url.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(url.hostname);
|
|
566
690
|
}
|
|
691
|
+
function checkedApiUrl(options, path) {
|
|
692
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) throw new Error(`"${options.appId}" isn't a valid Ralph app id. Check RALPH_APP_ID.`);
|
|
693
|
+
const url = new URL(path, options.apiUrl);
|
|
694
|
+
if (!(url.protocol === "https:" || url.protocol === "http:" && isLoopback(url))) throw new Error(`RALPH_API_URL must start with https:// (http:// only works for localhost), not ${options.apiUrl}.`);
|
|
695
|
+
if (!options.uploadKey) throw new Error(`To upload to Ralph, set RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`);
|
|
696
|
+
return url;
|
|
697
|
+
}
|
|
698
|
+
/** Sends one request, turning every way it can fail into a message that says what to fix. */
|
|
699
|
+
async function request(url, init, options, timeoutMs) {
|
|
700
|
+
let response;
|
|
701
|
+
try {
|
|
702
|
+
response = await fetch(url, {
|
|
703
|
+
...init,
|
|
704
|
+
method: "POST",
|
|
705
|
+
headers: {
|
|
706
|
+
...init.headers,
|
|
707
|
+
Authorization: `Bearer ${options.uploadKey}`
|
|
708
|
+
},
|
|
709
|
+
redirect: "error",
|
|
710
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (error instanceof TooBigError || error instanceof Error && error.cause instanceof TooBigError) throw new Error(TOO_BIG, { cause: error });
|
|
714
|
+
throw new Error(describeRequestFailure(error, url, timeoutMs), { cause: error });
|
|
715
|
+
}
|
|
716
|
+
if (!response.ok) throw new Error(describeHttpFailure(response.status, await responseDetail(response), url, options.appId));
|
|
717
|
+
try {
|
|
718
|
+
return await response.json();
|
|
719
|
+
} catch {
|
|
720
|
+
return {};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const unreadableReply = (url) => /* @__PURE__ */ new Error("Upload to Ralph failed. Ralph's reply couldn't be read. Update @ralphralphai/playwright to the latest version and run again." + customServerHint(url));
|
|
724
|
+
const PREPARE_TIMEOUT_MS = 3e4;
|
|
725
|
+
/**
|
|
726
|
+
* Reserves an artifact for the run. Its `resultUrl` works from now on, so the
|
|
727
|
+
* run can be followed while it uploads and while Ralph processes it.
|
|
728
|
+
*/
|
|
729
|
+
async function prepareArtifactUpload(options) {
|
|
730
|
+
const url = checkedApiUrl(options, "/api/upload/artifact/prepare");
|
|
731
|
+
const reply = await request(url, {
|
|
732
|
+
headers: { "content-type": "application/json" },
|
|
733
|
+
body: JSON.stringify({ externalAppId: options.appId })
|
|
734
|
+
}, options, PREPARE_TIMEOUT_MS);
|
|
735
|
+
if (reply["result"] !== "ok" || typeof reply["artifactId"] !== "string" || reply["resultUrl"] !== void 0 && typeof reply["resultUrl"] !== "string") throw unreadableReply(url);
|
|
736
|
+
return {
|
|
737
|
+
artifactId: reply["artifactId"],
|
|
738
|
+
resultUrl: reply["resultUrl"]
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Streams the run's bundle to the artifact `manifest.artifactId` names, which
|
|
743
|
+
* `prepareArtifactUpload` has to have reserved. Ralph processes it after this
|
|
744
|
+
* returns; the result page shows when it is ready.
|
|
745
|
+
*/
|
|
567
746
|
async function uploadRawGraph(manifest, screenshots, options) {
|
|
568
|
-
|
|
569
|
-
const url = new URL(`/api/upload/playwright/apps/${options.appId}/artifact`, options.apiUrl);
|
|
747
|
+
const url = checkedApiUrl(options, `/api/upload/web/apps/${options.appId}/artifact/${encodeURIComponent(manifest.artifactId)}`);
|
|
570
748
|
const storage = process.env["RALPH_UPLOAD_STORAGE"] || "gcs";
|
|
571
749
|
if (storage !== "gcs" && storage !== "local") throw new Error(`RALPH_UPLOAD_STORAGE must be gcs or local, not ${storage}.`);
|
|
572
750
|
if (storage === "local") url.searchParams.set("storage", "local");
|
|
573
|
-
if (!(url.protocol === "https:" || url.protocol === "http:" && isLoopback(url)) || !options.uploadKey) throw new Error("Ralph upload requires an HTTPS API URL (HTTP only for loopback hosts) and an upload key.");
|
|
574
751
|
const timeoutMs = options.timeoutMs ?? 12e4;
|
|
575
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Ralph
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
583
|
-
});
|
|
584
|
-
if (!response.ok) throw new Error(`Ralph upload failed (HTTP ${response.status}). Local Ralph artifacts are retained for retry.`);
|
|
585
|
-
const receipt = await response.json();
|
|
586
|
-
if (receipt.result !== "ok" || receipt.status !== "received" || receipt.artifactId !== manifest.artifactId || typeof receipt.resultId !== "string" || receipt.resultUrl !== void 0 && typeof receipt.resultUrl !== "string" || typeof receipt.replayed !== "boolean") throw new Error("Ralph server returned an invalid upload receipt.");
|
|
752
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("uploadTimeoutMs in the Ralph reporter options must be a positive number of milliseconds.");
|
|
753
|
+
const receipt = await request(url, {
|
|
754
|
+
headers: { "content-type": "application/gzip" },
|
|
755
|
+
body: Readable.toWeb(rawGraphBundleStream(manifest, screenshots)),
|
|
756
|
+
duplex: "half"
|
|
757
|
+
}, options, timeoutMs);
|
|
758
|
+
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);
|
|
587
759
|
return receipt;
|
|
588
760
|
}
|
|
761
|
+
const CREATE_KEY_HINT = "You can create an upload key in your project dashboard on Ralph.";
|
|
762
|
+
const SUPPORT_HINT = "If it keeps failing, contact Ralph support.";
|
|
763
|
+
/** Only users who set their own server need to hear about it. */
|
|
764
|
+
function customServerHint(url) {
|
|
765
|
+
return url.origin === new URL("https://api.ralphralph.ai").origin ? "" : ` Also check RALPH_API_URL (${url.origin}) is right.`;
|
|
766
|
+
}
|
|
767
|
+
function describeRequestFailure(error, url, timeoutMs) {
|
|
768
|
+
if (error instanceof Error && error.name === "TimeoutError") return `Upload to Ralph took longer than ${timeoutMs / 1e3}s and was stopped. Check your network, or raise uploadTimeoutMs in the reporter options.`;
|
|
769
|
+
return `Couldn't connect to Ralph (${error instanceof Error && error.cause instanceof Error ? error.cause.message : error instanceof Error ? error.message : String(error)}). Check that this machine can reach ${url.origin}.` + customServerHint(url);
|
|
770
|
+
}
|
|
771
|
+
function describeHttpFailure(status, detail, url, appId) {
|
|
772
|
+
let message;
|
|
773
|
+
if (status === 401) message = `Ralph didn't accept your upload key. Check RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`;
|
|
774
|
+
else if (status === 403) message = `Your upload key can't upload to the app "${appId}". Check RALPH_APP_ID is an app in the same project as the key.`;
|
|
775
|
+
else if (status === 404) message = `Ralph couldn't find the app "${appId}". Check RALPH_APP_ID.` + customServerHint(url);
|
|
776
|
+
else if (status === 413) message = "This run is too big to upload. Record fewer pages or screen sizes, or split the tests across runs.";
|
|
777
|
+
else if (status === 429) message = "Too many uploads right now. Wait a minute and run again.";
|
|
778
|
+
else if (status >= 500) message = `Something went wrong on Ralph's side. Run again later. ${SUPPORT_HINT}`;
|
|
779
|
+
else message = `Ralph couldn't accept this upload. Update @ralphralphai/playwright to the latest version and run again. ${SUPPORT_HINT}`;
|
|
780
|
+
return `Upload to Ralph failed. ${message} (HTTP ${status}${detail ? `: ${detail}` : ""})`;
|
|
781
|
+
}
|
|
782
|
+
/** The server's reason for rejecting the upload, kept short. */
|
|
783
|
+
async function responseDetail(response) {
|
|
784
|
+
let text;
|
|
785
|
+
try {
|
|
786
|
+
text = (await response.text()).trim();
|
|
787
|
+
} catch {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
const body = JSON.parse(text);
|
|
792
|
+
const message = body.result ?? body.message ?? body.error;
|
|
793
|
+
if (typeof message === "string") text = message;
|
|
794
|
+
} catch {}
|
|
795
|
+
text = text.replace(/\s+/g, " ");
|
|
796
|
+
return text.length > 200 ? text.slice(0, 200) + "…" : text || void 0;
|
|
797
|
+
}
|
|
589
798
|
//#endregion
|
|
590
|
-
export {
|
|
799
|
+
export { resolveUploadOptions as a, MANIFEST_ATTACHMENT as c, version as d, loadConfig as f, prepareArtifactUpload as i, REPORTER_ENV as l, resolveMode as m, bundleImagePath as n, resultLink as o, loadConfigSync as p, createRawGraphBundle as r, uploadRawGraph as s, RAW_GRAPH_BUNDLE_INDEX as t, Recorder as u };
|
|
591
800
|
|
|
592
|
-
//# sourceMappingURL=upload-
|
|
801
|
+
//# sourceMappingURL=upload-Bd6rKvUx.mjs.map
|