@ralphralphai/playwright 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["delay","packageInfo.version","base"],"sources":["../src/config.ts","../package.json","../src/image.ts","../src/layout.ts","../src/upload.ts","../src/recorder.ts","../src/screens.ts","../src/index.ts"],"sourcesContent":["import { readFileSync } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport configPackage from '@ralphralphai/config/package.json' with { type: 'json' };\nimport { WebRalphConfigSchema } from '@ralphralphai/config/zod';\nimport { parse, printParseErrorCode, type ParseError } from 'jsonc-parser';\n\nimport type { ConfigSnapshot, RalphOptions } from './types';\n\nexport function resolveMode(options: RalphOptions): 'off' | 'local' | 'upload' {\n const mode =\n options.mode ??\n process.env['RALPH_MODE'] ??\n (process.env['RALPH_UPLOAD_KEY'] ? 'upload' : 'off');\n if (mode === 'off' || mode === 'local' || mode === 'upload') {\n return mode;\n }\n\n throw new Error('RALPH_MODE must be off, local, or upload.');\n}\n\ntype ConfigSource = Pick<RalphOptions, 'config' | 'configPath'>;\n\nexport async function loadConfig(\n options: ConfigSource,\n root: string,\n): Promise<ConfigSnapshot> {\n const file = configFile(options, root);\n return snapshot(options.config ?? parseConfig(await readFile(file, 'utf8')));\n}\n\n// Playwright config files may be CommonJS, so project generation cannot await.\nexport function loadConfigSync(\n options: ConfigSource,\n root: string,\n): ConfigSnapshot {\n const file = configFile(options, root);\n return snapshot(options.config ?? parseConfig(readFileSync(file, 'utf8')));\n}\n\nfunction configFile(options: ConfigSource, root: string): string {\n if (options.config && options.configPath) {\n throw new Error(\n 'Provide either ralphOptions.config or configPath, not both.',\n );\n }\n return path.resolve(root, options.configPath ?? 'ralph.jsonc');\n}\n\nfunction parseConfig(text: string): unknown {\n const errors: ParseError[] = [];\n const value: unknown = parse(text, errors, { allowTrailingComma: true });\n if (errors.length) {\n throw new Error(\n 'Invalid Ralph config: ' +\n errors\n .map(\n (error) => printParseErrorCode(error.error) + ' at ' + error.offset,\n )\n .join(', '),\n );\n }\n return value;\n}\n\nfunction snapshot(value: unknown): ConfigSnapshot {\n WebRalphConfigSchema.parse(value);\n return {\n config: JSON.parse(JSON.stringify(value)) as ConfigSnapshot['config'],\n packageVersion: configPackage.version,\n };\n}\n","","import type { Page } from '@playwright/test';\n\n/**\n * Matches the crawler's full-page screenshots: lossless measured larger than this\n * on real sites, which are mostly photographic content.\n */\nconst WEBP_QUALITY = 80;\n\nexport type Screenshot = {\n bytes: Buffer;\n contentType: 'image/webp' | 'image/png';\n width: number;\n height: number;\n};\n\nexport async function takeScreenshot(\n page: Page,\n deadline: number,\n): Promise<Screenshot> {\n const options = { fullPage: true, scale: 'css' } as const;\n const webp = await page.screenshot({\n ...options,\n type: 'webp',\n quality: WEBP_QUALITY,\n timeout: Math.max(1, deadline - Date.now()),\n });\n // WebP stores each dimension in 14 bits, so a page over 16383px has no valid\n // encoding. Chromium signals that with an empty buffer rather than an error;\n // PNG has no such limit.\n if (webp.length > 0) {\n return { bytes: webp, contentType: 'image/webp', ...webpSize(webp) };\n }\n const png = await page.screenshot({\n ...options,\n type: 'png',\n timeout: Math.max(1, deadline - Date.now()),\n });\n return {\n bytes: png,\n contentType: 'image/png',\n width: png.readUInt32BE(16),\n height: png.readUInt32BE(20),\n };\n}\n\nexport function imageExtension(contentType: Screenshot['contentType']): string {\n return contentType === 'image/png' ? '.png' : '.webp';\n}\n\nfunction webpSize(bytes: Buffer): { width: number; height: number } {\n if (\n bytes.toString('ascii', 0, 4) !== 'RIFF' ||\n bytes.toString('ascii', 8, 12) !== 'WEBP'\n ) {\n throw new Error('The screenshot is not a WebP image.');\n }\n const chunk = bytes.toString('ascii', 12, 16);\n if (chunk === 'VP8X') {\n return {\n width: 1 + bytes.readUIntLE(24, 3),\n height: 1 + bytes.readUIntLE(27, 3),\n };\n }\n if (chunk === 'VP8 ') {\n return {\n width: bytes.readUInt16LE(26) & 0x3fff,\n height: bytes.readUInt16LE(28) & 0x3fff,\n };\n }\n if (chunk === 'VP8L') {\n const bits = bytes.readUInt32LE(21);\n return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };\n }\n throw new Error('Unsupported WebP chunk: ' + chunk);\n}\n","import type { Page } from '@playwright/test';\n\nimport type { PageLayout } from './types';\n\nexport function readLayout(page: Page): Promise<PageLayout> {\n return page\n .locator('[data-track-id]')\n .filter({ visible: true })\n .evaluateAll((elements) => ({\n viewport: { width: window.innerWidth, height: window.innerHeight },\n document: {\n width: Math.max(\n document.documentElement.scrollWidth,\n document.documentElement.clientWidth,\n document.body?.scrollWidth ?? 0,\n ),\n height: Math.max(\n document.documentElement.scrollHeight,\n document.documentElement.clientHeight,\n document.body?.scrollHeight ?? 0,\n ),\n },\n scroll: { x: window.scrollX, y: window.scrollY },\n devicePixelRatio: window.devicePixelRatio,\n trackedElements: elements.flatMap((element) => {\n const trackId = element.getAttribute('data-track-id');\n const rect = element.getBoundingClientRect();\n // Off-screen horizontally (drawers, carousel slides) is dropped like\n // the crawler does; below the fold is kept, since the screenshot is\n // full-page.\n if (\n !trackId ||\n rect.width <= 0 ||\n rect.height <= 0 ||\n rect.right <= 0 ||\n rect.left >= window.innerWidth\n ) {\n return [];\n }\n return [\n {\n trackId,\n x: rect.x + window.scrollX,\n y: rect.y + window.scrollY,\n width: rect.width,\n height: rect.height,\n },\n ];\n }),\n }));\n}\n","import { promisify } from 'node:util';\nimport { gzip } from 'node:zlib';\n\nimport { pack } from 'tar-stream';\n\nimport { imageExtension, type Screenshot } from './image';\nimport type { RawGraphManifest, RalphOptions } from './types';\n\nexport type UploadOptions = {\n apiUrl: string;\n appId: string;\n uploadKey: string;\n timeoutMs?: number;\n};\nexport type RawGraphScreenshot = {\n nodeId: string;\n contentType: Screenshot['contentType'];\n bytes: Uint8Array;\n};\nexport type UploadReceipt = {\n result: 'ok';\n status: 'received';\n attemptId: string;\n resultId: string;\n replayed: boolean;\n};\n\nexport function resolveUploadOptions(options: RalphOptions): UploadOptions {\n const uploadKey = process.env['RALPH_UPLOAD_KEY'];\n const apiUrl = options.apiUrl ?? process.env['RALPH_API_URL'];\n const appId = options.appId ?? process.env['RALPH_APP_ID'];\n if (!uploadKey || !apiUrl || !appId) {\n throw new Error(\n 'Ralph upload requires RALPH_UPLOAD_KEY, RALPH_API_URL (or ralphOptions.apiUrl), and RALPH_APP_ID (or ralphOptions.appId).',\n );\n }\n return { uploadKey, apiUrl, appId, timeoutMs: options.uploadTimeoutMs };\n}\n\nexport const RAW_GRAPH_BUNDLE_INDEX = 'raw_graph.json';\nexport const RAW_GRAPH_BUNDLE_IMAGE_DIR = 'assets/images';\n\nexport type RawGraphBundleIndex = {\n manifest: RawGraphManifest;\n screenshots: { nodeId: string; path: string }[];\n};\n\n/**\n * Packs the manifest and screenshots into a gzipped tar archive:\n *\n * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }\n * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)\n */\nexport async function createRawGraphBundle(\n manifest: RawGraphManifest,\n screenshots: readonly RawGraphScreenshot[],\n): Promise<Uint8Array> {\n if (\n Buffer.byteLength(JSON.stringify(manifest)) > 2 * 1024 * 1024 ||\n screenshots.length > 500 ||\n screenshots.reduce(\n (total, screenshot) => total + screenshot.bytes.byteLength,\n 0,\n ) >\n 64 * 1024 * 1024\n ) {\n throw new Error('Ralph raw graph exceeds upload limits.');\n }\n\n const images = screenshots.map(({ nodeId, contentType, bytes }) => {\n if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) {\n throw new Error(`Ralph node id is not path-safe: ${nodeId}`);\n }\n return {\n nodeId,\n path: `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`,\n bytes,\n };\n });\n const index: RawGraphBundleIndex = {\n manifest,\n screenshots: images.map(({ nodeId, path }) => ({ nodeId, path })),\n };\n\n // Fixed attributes keep retries of the same attempt byte-identical.\n const attrs = { mtime: new Date(0), uid: 0, gid: 0 };\n const tar = pack();\n tar.entry({ name: RAW_GRAPH_BUNDLE_INDEX, ...attrs }, JSON.stringify(index));\n for (const { path, bytes } of images) {\n tar.entry({ name: path, ...attrs }, Buffer.from(bytes));\n }\n tar.finalize();\n const chunks: Buffer[] = [];\n for await (const chunk of tar) {\n chunks.push(chunk as Buffer);\n }\n const compressed = await promisify(gzip)(Buffer.concat(chunks));\n if (compressed.byteLength > 64 * 1024 * 1024) {\n throw new Error('Ralph compressed raw graph bundle exceeds upload limits.');\n }\n\n return compressed;\n}\n\nfunction isLoopback(url: URL): boolean {\n return (\n url.hostname === 'localhost' ||\n url.hostname === '[::1]' ||\n /^127(\\.\\d{1,3}){3}$/.test(url.hostname)\n );\n}\n\nexport async function uploadRawGraph(\n manifest: RawGraphManifest,\n screenshots: readonly RawGraphScreenshot[],\n options: UploadOptions,\n): Promise<UploadReceipt> {\n if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) {\n throw new Error(`Ralph app id is not valid: ${options.appId}`);\n }\n const url = new URL(\n `/api/upload/playwright/apps/${options.appId}/attempts`,\n options.apiUrl,\n );\n if (\n !(\n url.protocol === 'https:' ||\n (url.protocol === 'http:' && isLoopback(url))\n ) ||\n !options.uploadKey\n ) {\n throw new Error(\n 'Ralph upload requires an HTTPS API URL (HTTP only for loopback hosts) and an upload key.',\n );\n }\n\n const timeoutMs = options.timeoutMs ?? 30_000;\n if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {\n throw new Error('Ralph uploadTimeoutMs must be positive and finite.');\n }\n\n const blob = new Blob(\n [new Uint8Array(await createRawGraphBundle(manifest, screenshots))],\n { type: 'application/gzip' },\n );\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { Authorization: `Bearer ${options.uploadKey}` },\n body: blob,\n redirect: 'error',\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!response.ok) {\n throw new Error(\n `Ralph upload failed (HTTP ${response.status}). Local Ralph artifacts are retained for retry.`,\n );\n }\n\n const receipt = (await response.json()) as Partial<UploadReceipt>;\n if (\n receipt.result !== 'ok' ||\n receipt.status !== 'received' ||\n receipt.attemptId !== manifest.attemptId ||\n typeof receipt.resultId !== 'string' ||\n typeof receipt.replayed !== 'boolean'\n ) {\n throw new Error('Ralph server returned an invalid upload receipt.');\n }\n return receipt as UploadReceipt;\n}\n","import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, writeFile } from 'node:fs/promises';\nimport { setTimeout as delay } from 'node:timers/promises';\n\nimport type { BrowserContext, Frame, Page, TestInfo } from '@playwright/test';\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\n\nimport packageInfo from '../package.json';\nimport { imageExtension, takeScreenshot, type Screenshot } from './image';\nimport { readLayout } from './layout';\nimport type {\n RawGraphManifest,\n RawNode,\n RecordNodeOptions,\n ConfigSnapshot,\n Ralph,\n RalphOptions,\n} from './types';\nimport { uploadRawGraph, type UploadOptions } from './upload';\n\nexport const MANIFEST_ATTACHMENT = 'ralph-raw-graph';\n\nconst DEFAULT_SETTLE_MS = 150;\nconst DEFAULT_RECORD_TIMEOUT_MS = 5_000;\n\n/** Where the raw graph and screenshots are written, inside Playwright's per-test output directory. */\nconst OUTPUT_DIR = 'ralph';\n\ntype PageInfo = RawGraphManifest['pages'][number];\n\ntype NodeRequest = Omit<RawNode, 'status'>;\n\ntype RecordedNode = Extract<RawNode, { status: 'recorded' }>;\n\n/** What the recorder tracks for one browser tab. */\ntype PageState = {\n page: Page;\n info: PageInfo;\n\n /** The URL of the last navigation, so a reload is not recorded again. */\n lastUrl: string;\n\n /**\n * Bumped on every main-frame navigation, including reloads. A recording that\n * sees a different generation when it finishes was overtaken by one.\n */\n generation: number;\n\n /** The next node's position within this tab. */\n sequence: number;\n\n /** The last queued recording. Recordings in a tab run one at a time. */\n tail: Promise<void>;\n\n /** Resolves once `info.openerPageId` is known. */\n opener: Promise<void>;\n\n onNavigation: (frame: Frame) => void;\n};\n\nexport class Recorder implements Ralph {\n /**\n * Identifies this run of the test. A retry gets a new recorder, and so a new\n * id, which is what lets the server tell retries apart and dedupe re-uploads.\n */\n private readonly attemptId = randomUUID();\n private readonly startedAt = new Date().toISOString();\n\n /** How long to wait after a navigation's DOM loads before recording. */\n private readonly settleMs: number;\n\n /**\n * The budget for recording one node, from settling through the screenshot.\n * Past it, the node is recorded as failed.\n */\n private readonly recordTimeoutMs: number;\n\n /**\n * Every context being watched. `onPage` is kept so `stopContext` can remove\n * the exact listener `observe` added. The id is stored in the manifest's\n * `pages[].contextId`.\n */\n private readonly contexts = new Map<\n BrowserContext,\n { id: string; onPage: (page: Page) => void }\n >();\n\n /**\n * Every tab seen in a watched context, including closed ones, so their\n * nodes and opener links still reach the manifest.\n */\n private readonly pages = new Map<Page, PageState>();\n\n /**\n * Each node's outcome, in completion order. Nodes in one tab complete\n * in sequence, but tabs interleave; `finish` sorts them.\n */\n private readonly nodes: RawNode[] = [];\n\n /**\n * When each node was requested, keyed by node id, for linking a\n * popup's first node to its opener's latest. Numbers, so they compare\n * without parsing each node's `requestedAt` string.\n */\n private readonly requestedAtMs = new Map<string, number>();\n\n /** Set by `finish`, after which `observe` throws. */\n private closed = false;\n\n constructor(\n private readonly options: RalphOptions,\n private readonly config: ConfigSnapshot,\n private readonly testInfo: TestInfo,\n private readonly upload?: UploadOptions,\n ) {\n this.settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;\n this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_RECORD_TIMEOUT_MS;\n\n const validTimings =\n Number.isFinite(this.settleMs) &&\n this.settleMs >= 0 &&\n Number.isFinite(this.recordTimeoutMs) &&\n this.recordTimeoutMs > this.settleMs;\n if (!validTimings) {\n throw new Error(\n 'Ralph recordTimeoutMs must be finite and greater than settleMs, which must be non-negative.',\n );\n }\n }\n\n // ---------------------------------------------------------------------------\n // Public API\n // ---------------------------------------------------------------------------\n\n /** Starts recording URL changes in every current and future tab of `context`. */\n observe(context: BrowserContext): void {\n if (this.closed) {\n throw new Error('The Ralph recorder has finished.');\n }\n if (this.contexts.has(context)) {\n return;\n }\n\n const id = 'context-' + this.contexts.size;\n const onPage = (page: Page) => this.watchPage(page, id);\n this.contexts.set(context, { id, onPage });\n\n context.on('page', onPage);\n for (const page of context.pages()) {\n onPage(page);\n }\n }\n\n /** Records the page as it is now, e.g. an open dialog at the same URL. */\n async recordNode(page: Page, options: RecordNodeOptions = {}): Promise<void> {\n this.observe(page.context());\n const state = this.pages.get(page);\n if (!state) {\n throw new Error('Cannot record a closed browser page.');\n }\n await this.enqueue(state, 'explicit', options);\n }\n\n /** Waits for every queued recording to finish. */\n async flush(): Promise<void> {\n await Promise.all([...this.pages.values()].map((state) => state.tail));\n }\n\n /** Stops watching `context` and waits for its queued recordings. */\n async stopContext(context: BrowserContext): Promise<void> {\n const entry = this.contexts.get(context);\n if (entry) {\n context.off('page', entry.onPage);\n }\n\n const states = [...this.pages.values()].filter(\n (state) => state.page.context() === context,\n );\n for (const state of states) {\n state.page.off('framenavigated', state.onNavigation);\n }\n await Promise.all(states.map((state) => state.tail));\n }\n\n /**\n * Stops recording, then writes the manifest, uploads it if configured, and\n * fails a passing test whose nodes failed to record.\n */\n async finish(): Promise<void> {\n this.closed = true;\n await Promise.all(\n [...this.contexts.keys()].map((context) => this.stopContext(context)),\n );\n await Promise.all([...this.pages.values()].map((state) => state.opener));\n\n const nodes = this.nodes.sort(\n (a, b) => a.pageId.localeCompare(b.pageId) || a.sequence - b.sequence,\n );\n linkPreviousNodes(\n nodes,\n (pageId) => this.pageInfo(pageId)?.openerPageId,\n (nodeId) => this.requestedAtMs.get(nodeId) ?? 0,\n );\n\n const failures = nodes.filter((item) => item.status === 'failed');\n const manifest = this.buildManifest(nodes, failures.length);\n await this.writeManifest(manifest);\n if (this.upload) {\n await this.uploadManifest(manifest, this.upload);\n }\n\n if (failures.length && this.testInfo.status === 'passed') {\n throw new Error(\n 'Ralph could not record ' +\n failures.length +\n ' page state(s). See the ralph-raw-graph attachment.',\n );\n }\n }\n\n // ---------------------------------------------------------------------------\n // Watching tabs\n // ---------------------------------------------------------------------------\n\n private watchPage(page: Page, contextId: string): void {\n if (this.pages.has(page)) {\n return;\n }\n\n const state: PageState = {\n page,\n info: {\n pageId: 'page-' + this.pages.size,\n contextId,\n browserName: page.context().browser()?.browserType().name(),\n },\n lastUrl: '',\n generation: 0,\n sequence: 0,\n tail: Promise.resolve(),\n opener: Promise.resolve(),\n onNavigation: (frame) => {\n if (frame === page.mainFrame()) {\n this.urlChanged(state);\n }\n },\n };\n this.pages.set(page, state);\n\n state.opener = page\n .opener()\n .then((opener) => {\n if (opener) {\n state.info.openerPageId = this.pages.get(opener)?.info.pageId;\n }\n })\n .catch(() => undefined);\n\n page.on('framenavigated', state.onNavigation);\n this.urlChanged(state);\n }\n\n private urlChanged(state: PageState): void {\n state.generation++;\n\n const url = state.page.url();\n if (url === state.lastUrl) {\n return;\n }\n state.lastUrl = url;\n\n if (isHttpUrl(url)) {\n // Automatic recordings never fail the test; failures land in the manifest.\n void this.enqueue(state, 'url-change', {}).catch(() => undefined);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Recording\n // ---------------------------------------------------------------------------\n\n /** Queues a recording behind the tab's earlier ones. */\n private enqueue(\n state: PageState,\n trigger: RawNode['trigger'],\n options: RecordNodeOptions,\n ): Promise<void> {\n const actualUrl = state.page.url();\n const url =\n options.url === undefined\n ? actualUrl\n : new URL(options.url, actualUrl).href;\n if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) {\n throw new Error(\n 'Ralph recordings require HTTP or HTTPS page and recorded URLs.',\n );\n }\n\n const request: NodeRequest = {\n nodeId: randomUUID(),\n pageId: state.info.pageId,\n sequence: state.sequence++,\n requestedAt: new Date().toISOString(),\n actualUrl,\n url,\n trigger,\n ...(options.state === undefined ? {} : { state: options.state }),\n };\n this.requestedAtMs.set(request.nodeId, Date.now());\n\n const generation = state.generation;\n const task = state.tail.then(() => this.record(state, generation, request));\n state.tail = task.catch(() => undefined);\n\n return task;\n }\n\n /**\n * Records one node and its outcome. Fails if the tab navigates or\n * closes before it finishes, or if it runs past the record timeout.\n */\n private async record(\n state: PageState,\n generation: number,\n request: NodeRequest,\n ): Promise<void> {\n const { page } = state;\n const deadline = startDeadline(\n this.recordTimeoutMs,\n 'Ralph recording timed out.',\n );\n const { wait } = deadline;\n const navigatedAway = () =>\n state.generation !== generation || page.url() !== request.actualUrl;\n\n const check = () => {\n deadline.signal.throwIfAborted();\n if (navigatedAway()) {\n throw new Error('The page navigated before its recording completed.');\n }\n if (page.isClosed()) {\n throw new Error('The page closed before its recording completed.');\n }\n };\n\n try {\n check();\n if (request.trigger === 'url-change') {\n await this.waitForPageToSettle(page, deadline, check);\n }\n\n // Geometry is read on both sides of the screenshot to detect layout drift.\n check();\n const before = await wait(readLayout(page));\n check();\n const image = await wait(takeScreenshot(page, deadline.endsAt));\n check();\n const after = await wait(readLayout(page));\n check();\n\n this.nodes.push({\n ...request,\n status: 'recorded',\n recordedAt: new Date().toISOString(),\n layout: before,\n geometryStable: JSON.stringify(before) === JSON.stringify(after),\n screenshot: await this.saveScreenshot(request.nodeId, image),\n });\n } catch (error) {\n this.nodes.push({\n ...request,\n status: navigatedAway() ? 'superseded' : 'failed',\n reason: error instanceof Error ? error.message : String(error),\n });\n if (request.trigger === 'explicit') {\n throw error;\n }\n } finally {\n deadline.cancel();\n }\n }\n\n /** After a navigation: the DOM, the settle delay, then the `ready` hook. */\n private async waitForPageToSettle(\n page: Page,\n { wait, signal }: Deadline,\n check: () => void,\n ): Promise<void> {\n await wait(\n page.waitForLoadState('domcontentloaded', {\n timeout: this.recordTimeoutMs,\n }),\n );\n await wait(delay(this.settleMs, undefined, { signal }));\n check();\n if (this.options.ready) {\n await wait(this.options.ready(page, signal));\n }\n }\n\n /** Writes the image to the test output and attaches it to the report. */\n private async saveScreenshot(\n nodeId: string,\n image: Screenshot,\n ): Promise<RecordedNode['screenshot']> {\n const path = OUTPUT_DIR + '/' + nodeId + imageExtension(image.contentType);\n const attachmentName = 'ralph-screenshot-' + nodeId;\n\n await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });\n await writeFile(this.testInfo.outputPath(path), image.bytes);\n await this.testInfo.attach(attachmentName, {\n path: this.testInfo.outputPath(path),\n contentType: image.contentType,\n });\n\n return {\n path,\n attachmentName,\n width: image.width,\n height: image.height,\n scale: 'css',\n contentType: image.contentType,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Manifest\n // ---------------------------------------------------------------------------\n\n private pageInfo(pageId: string): PageInfo | undefined {\n return [...this.pages.values()].find(\n (state) => state.info.pageId === pageId,\n )?.info;\n }\n\n private buildManifest(\n nodes: RawNode[],\n failureCount: number,\n ): RawGraphManifest {\n const { testInfo } = this;\n const testPassed = testInfo.status === 'passed';\n\n return {\n formatVersion: 1,\n producer: {\n name: '@ralphralphai/playwright',\n version: packageInfo.version,\n playwrightVersion: playwrightPackage.version,\n },\n runId: this.options.runId ?? process.env['RALPH_RUN_ID'],\n buildId: this.options.buildId ?? process.env['RALPH_BUILD_ID'],\n attemptId: this.attemptId,\n test: {\n id: testInfo.testId,\n titlePath: testInfo.titlePath,\n project: testInfo.project.name,\n retry: testInfo.retry,\n repeatEachIndex: testInfo.repeatEachIndex,\n workerIndex: testInfo.workerIndex,\n parallelIndex: testInfo.parallelIndex,\n shard: testInfo.config.shard,\n // Failed nodes fail the test in `finish`, after this is written.\n status:\n failureCount && testPassed\n ? 'failed'\n : (testInfo.status ?? 'unknown'),\n expectedStatus: testInfo.expectedStatus,\n },\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\n config: this.config,\n pages: [...this.pages.values()].map((state) => state.info),\n nodes,\n complete:\n testPassed &&\n nodes.length > 0 &&\n nodes.every(\n (item) => item.status === 'recorded' && item.geometryStable,\n ),\n };\n }\n\n private async writeManifest(manifest: RawGraphManifest): Promise<void> {\n const file = this.testInfo.outputPath(OUTPUT_DIR, 'raw_graph.json');\n await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });\n await writeFile(file, JSON.stringify(manifest, null, 2) + '\\n');\n await this.testInfo.attach(MANIFEST_ATTACHMENT, {\n path: file,\n contentType: 'application/json',\n });\n }\n\n private async uploadManifest(\n manifest: RawGraphManifest,\n upload: UploadOptions,\n ): Promise<void> {\n const recorded = manifest.nodes.filter(\n (item): item is RecordedNode => item.status === 'recorded',\n );\n const screenshots = [];\n for (const { nodeId, screenshot } of recorded) {\n screenshots.push({\n nodeId,\n contentType: screenshot.contentType,\n bytes: await readFile(this.testInfo.outputPath(screenshot.path)),\n });\n }\n\n const receipt = await uploadRawGraph(manifest, screenshots, upload);\n await this.testInfo.attach('ralph-upload-receipt', {\n body: JSON.stringify(receipt),\n contentType: 'application/json',\n });\n }\n}\n\n// -----------------------------------------------------------------------------\n// Helpers\n// -----------------------------------------------------------------------------\n\nfunction isHttpUrl(url: string): boolean {\n return /^https?:/.test(url);\n}\n\ntype Deadline = {\n signal: AbortSignal;\n /** Epoch milliseconds at which the deadline passes. */\n endsAt: number;\n /** Rejects with the timeout error if `promise` outlives the deadline. */\n wait: <T>(promise: Promise<T>) => Promise<T>;\n cancel: () => void;\n};\n\nfunction startDeadline(timeoutMs: number, message: string): Deadline {\n const controller = new AbortController();\n const { signal } = controller;\n const timer = setTimeout(\n () => controller.abort(new Error(message)),\n timeoutMs,\n );\n const aborted = new Promise<never>((_, reject) => {\n signal.addEventListener('abort', () => reject(signal.reason), {\n once: true,\n });\n });\n // Nothing may be waiting when the deadline passes.\n aborted.catch(() => undefined);\n\n return {\n signal,\n endsAt: Date.now() + timeoutMs,\n wait: (promise) => Promise.race([promise, aborted]),\n cancel: () => clearTimeout(timer),\n };\n}\n\n/**\n * Sets `previousNodeId` on each node: the one before it in the same\n * tab, or for a popup's first node, its opener's latest. Superseded nodes\n * are skipped, since their page state was never shown.\n *\n * `nodes` must be sorted by page, then sequence.\n */\nfunction linkPreviousNodes(\n nodes: RawNode[],\n openerOf: (pageId: string) => string | undefined,\n requestedAtMs: (nodeId: string) => number,\n): void {\n const byPage = new Map<string, RawNode[]>();\n for (const item of nodes) {\n byPage.set(item.pageId, [...(byPage.get(item.pageId) ?? []), item]);\n }\n\n const openerNodeBefore = (item: RawNode) => {\n const openerPageId = openerOf(item.pageId);\n const requestedAt = requestedAtMs(item.nodeId);\n return byPage\n .get(openerPageId ?? '')\n ?.filter((candidate) => requestedAtMs(candidate.nodeId) <= requestedAt)\n .at(-1);\n };\n\n // First the raw chain, superseded nodes included.\n const previous = new Map<RawNode, RawNode>();\n for (const items of byPage.values()) {\n items.forEach((item, index) => {\n const from = index > 0 ? items[index - 1] : openerNodeBefore(item);\n if (from) {\n previous.set(item, from);\n }\n });\n }\n\n // Then skip past superseded ones, e.g. a redirect or a replaced route.\n for (const item of nodes) {\n let from = previous.get(item);\n while (from?.status === 'superseded') {\n from = previous.get(from);\n }\n if (from) {\n item.previousNodeId = from.nodeId;\n }\n }\n}\n","import type {\n PlaywrightTestOptions,\n PlaywrightWorkerOptions,\n Project,\n TestInfo,\n} from '@playwright/test';\nimport type { ScreenSize } from '@ralphralphai/config';\n\nimport { loadConfigSync } from './config';\nimport type { RalphOptions } from './types';\n\ntype RalphProject = Project<\n PlaywrightTestOptions & { ralphOptions: RalphOptions },\n PlaywrightWorkerOptions\n>;\n\nexport const SCREEN_TAG_PREFIX = '@ralph-screen:';\n\nexport type RalphProjectOptions = Pick<\n RalphOptions,\n 'config' | 'configPath'\n> & {\n /** Resolves a relative `configPath`; defaults to the current directory. */\n root?: string;\n};\n\ntype ScreenMetadata = { screenSize: ScreenSize; screenSizes: ScreenSize[] };\n\nexport function screenLabel(size: ScreenSize): string {\n return size.width + 'x' + size.height;\n}\n\n/**\n * Expands `base` into one project per `screenSizes` entry of the Ralph config.\n * Each project's viewport is that screen size.\n */\nexport function ralphProjects(\n base: RalphProject = {},\n options: RalphProjectOptions = {},\n): RalphProject[] {\n const { screenSizes } = loadConfigSync(\n options,\n options.root ?? process.cwd(),\n ).config;\n if (!screenSizes.length) {\n throw new Error('The Ralph config declares no screenSizes.');\n }\n return screenSizes.map((screenSize) => {\n const label = screenLabel(screenSize);\n const ralph: ScreenMetadata = { screenSize, screenSizes };\n return {\n ...base,\n name: base.name ? base.name + ' ' + label : label,\n use: {\n ...base.use,\n viewport: { width: screenSize.width, height: screenSize.height },\n },\n metadata: { ...base.metadata, ralph },\n };\n });\n}\n\n/**\n * Skips a test whose `@ralph-screen:WxH` tags exclude the current project's\n * screen size. Untagged tests run at every size; other projects are untouched.\n */\nexport function applyScreenSelection(testInfo: TestInfo): void {\n const selected = testInfo.tags\n .filter((tag) => tag.startsWith(SCREEN_TAG_PREFIX))\n .map((tag) => tag.slice(SCREEN_TAG_PREFIX.length));\n const metadata = (testInfo.project.metadata as { ralph?: ScreenMetadata })\n .ralph;\n if (!selected.length || !metadata) {\n return;\n }\n const known = metadata.screenSizes.map(screenLabel);\n const unknown = selected.filter((label) => !known.includes(label));\n if (unknown.length) {\n throw new Error(\n 'Unknown Ralph screen size ' +\n unknown.join(', ') +\n '; the config declares ' +\n known.join(', ') +\n '.',\n );\n }\n const current = screenLabel(metadata.screenSize);\n testInfo.skip(\n !selected.includes(current),\n 'Not selected for Ralph screen size ' + current + '.',\n );\n}\n","import path from 'node:path';\n\nimport { test as base } from '@playwright/test';\nimport type {\n PlaywrightTestArgs,\n PlaywrightTestOptions,\n PlaywrightWorkerArgs,\n PlaywrightWorkerOptions,\n TestType,\n} from '@playwright/test';\n\nimport { loadConfig, resolveMode } from './config';\nimport { Recorder } from './recorder';\nimport { applyScreenSelection } from './screens';\nimport type { ConfigSnapshot, Ralph, RalphOptions } from './types';\nimport { resolveUploadOptions } from './upload';\n\nexport { expect, mergeTests } from '@playwright/test';\nexport type { WebRalphConfig } from '@ralphralphai/config';\nexport { createRawGraphBundle, uploadRawGraph } from './upload';\nexport type {\n UploadOptions,\n UploadReceipt,\n RawGraphScreenshot,\n RawGraphBundleIndex,\n} from './upload';\nexport { MANIFEST_ATTACHMENT } from './recorder';\nexport { ralphProjects, SCREEN_TAG_PREFIX } from './screens';\nexport type { RalphProjectOptions } from './screens';\nexport type {\n RawGraphManifest,\n RawNode,\n RecordNodeOptions,\n ConfigSnapshot,\n PageLayout,\n Ralph,\n RalphOptions,\n TrackedElement,\n} from './types';\n\nexport type RalphFixtures = { ralph: Ralph; ralphOptions: RalphOptions };\n\ntype InternalFixtures = {\n _ralphScreen: void;\n _ralphRecorder: Recorder | null;\n};\ntype WorkerFixtures = { _ralphConfigs: Map<string, Promise<ConfigSnapshot>> };\n\nexport const test: TestType<\n PlaywrightTestArgs & PlaywrightTestOptions & RalphFixtures,\n PlaywrightWorkerArgs & PlaywrightWorkerOptions\n> = base.extend<RalphFixtures & InternalFixtures, WorkerFixtures>({\n ralphOptions: [{}, { option: true }],\n _ralphConfigs: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use) => {\n await use(new Map());\n },\n { scope: 'worker' },\n ],\n _ralphScreen: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use, testInfo) => {\n // Screen selection chooses which flows run, so it applies in every mode.\n applyScreenSelection(testInfo);\n await use();\n },\n { auto: true },\n ],\n _ralphRecorder: [\n // _ralphScreen is specified here so that the playwright treat that it is\n // a dependency of this fixture.\n async ({ ralphOptions, _ralphConfigs, _ralphScreen }, use, testInfo) => {\n if (\n !testInfo.tags.includes('@ralph') ||\n resolveMode(ralphOptions) === 'off'\n ) {\n await use(null);\n return;\n }\n\n const upload =\n resolveMode(ralphOptions) === 'upload'\n ? resolveUploadOptions(ralphOptions)\n : undefined;\n\n const root = testInfo.config.configFile\n ? path.dirname(testInfo.config.configFile)\n : process.cwd();\n\n const key = JSON.stringify({\n root,\n config: ralphOptions.config,\n configPath: ralphOptions.configPath,\n });\n\n let pending = _ralphConfigs.get(key);\n if (!pending) {\n pending = loadConfig(ralphOptions, root);\n _ralphConfigs.set(key, pending);\n }\n\n const recorder = new Recorder(\n ralphOptions,\n await pending,\n testInfo,\n upload,\n );\n\n try {\n await use(recorder);\n } finally {\n await recorder.finish();\n }\n },\n { auto: true, timeout: 30_000 },\n ],\n ralph: async ({ _ralphRecorder, ralphOptions }, use) => {\n const checkOptIn = () => {\n if (resolveMode(ralphOptions) !== 'off') {\n throw new Error('Opt this test into Ralph with the @ralph tag.');\n }\n };\n\n await use(\n _ralphRecorder ?? {\n recordNode: async () => {\n checkOptIn();\n },\n flush: async () => {},\n observe: () => {\n checkOptIn();\n },\n },\n );\n },\n context: async ({ context, _ralphRecorder }, use) => {\n _ralphRecorder?.observe(context);\n try {\n await use(context);\n } finally {\n // The base context fixture closes its pages next; pending recordings still need them alive.\n await _ralphRecorder?.stopContext(context);\n }\n },\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["base"],"sources":["../src/screens.ts","../src/index.ts"],"sourcesContent":["import type {\n PlaywrightTestOptions,\n PlaywrightWorkerOptions,\n Project,\n TestInfo,\n} from '@playwright/test';\nimport type { ScreenSize } from '@ralphralphai/config';\n\nimport { loadConfigSync } from './config';\nimport type { RalphOptions } from './types';\n\ntype RalphProject = Project<\n PlaywrightTestOptions & { ralphOptions: RalphOptions },\n PlaywrightWorkerOptions\n>;\n\nexport const SCREEN_TAG_PREFIX = '@ralph-screen:';\n\nexport type RalphProjectOptions = Pick<\n RalphOptions,\n 'config' | 'configPath'\n> & {\n /** Resolves a relative `configPath`; defaults to the current directory. */\n root?: string;\n};\n\ntype ScreenMetadata = { screenSize: ScreenSize; screenSizes: ScreenSize[] };\n\nexport function screenLabel(size: ScreenSize): string {\n return size.width + 'x' + size.height;\n}\n\n/**\n * Expands `base` into one project per `screenSizes` entry of the Ralph config.\n * Each project's viewport is that screen size.\n */\nexport function ralphProjects(\n base: RalphProject = {},\n options: RalphProjectOptions = {},\n): RalphProject[] {\n const { screenSizes } = loadConfigSync(\n options,\n options.root ?? process.cwd(),\n ).config;\n if (!screenSizes.length) {\n throw new Error('The Ralph config declares no screenSizes.');\n }\n return screenSizes.map((screenSize) => {\n const label = screenLabel(screenSize);\n const ralph: ScreenMetadata = { screenSize, screenSizes };\n return {\n ...base,\n name: base.name ? base.name + ' ' + label : label,\n use: {\n ...base.use,\n viewport: { width: screenSize.width, height: screenSize.height },\n },\n metadata: { ...base.metadata, ralph },\n };\n });\n}\n\n/**\n * Skips a test whose `@ralph-screen:WxH` tags exclude the current project's\n * screen size. Untagged tests run at every size; other projects are untouched.\n */\nexport function applyScreenSelection(testInfo: TestInfo): void {\n const selected = testInfo.tags\n .filter((tag) => tag.startsWith(SCREEN_TAG_PREFIX))\n .map((tag) => tag.slice(SCREEN_TAG_PREFIX.length));\n const metadata = (testInfo.project.metadata as { ralph?: ScreenMetadata })\n .ralph;\n if (!selected.length || !metadata) {\n return;\n }\n const known = metadata.screenSizes.map(screenLabel);\n const unknown = selected.filter((label) => !known.includes(label));\n if (unknown.length) {\n throw new Error(\n 'Unknown Ralph screen size ' +\n unknown.join(', ') +\n '; the config declares ' +\n known.join(', ') +\n '.',\n );\n }\n const current = screenLabel(metadata.screenSize);\n testInfo.skip(\n !selected.includes(current),\n 'Not selected for Ralph screen size ' + current + '.',\n );\n}\n","import path from 'node:path';\n\nimport { test as base } from '@playwright/test';\nimport type {\n PlaywrightTestArgs,\n PlaywrightTestOptions,\n PlaywrightWorkerArgs,\n PlaywrightWorkerOptions,\n TestType,\n} from '@playwright/test';\n\nimport { loadConfig, resolveMode } from './config';\nimport { Recorder, REPORTER_ENV } from './recorder';\nimport { applyScreenSelection } from './screens';\nimport type { ConfigSnapshot, Ralph, RalphOptions } from './types';\n\nexport { expect, mergeTests } from '@playwright/test';\nexport type { WebRalphConfig } from '@ralphralphai/config';\nexport { createRawGraphBundle, uploadRawGraph } from './upload';\nexport type {\n UploadOptions,\n UploadReceipt,\n RawGraphScreenshot,\n RawGraphBundleIndex,\n} from './upload';\nexport { MANIFEST_ATTACHMENT } from './recorder';\nexport { ralphProjects, SCREEN_TAG_PREFIX } from './screens';\nexport type { RalphProjectOptions } from './screens';\nexport type { RalphReporterOptions } from './reporter';\nexport type {\n RawGraphManifest,\n RawGraph,\n RawGraphFile,\n RawPage,\n RawNode,\n RecordNodeOptions,\n ConfigSnapshot,\n PageLayout,\n Ralph,\n RalphOptions,\n TrackedElement,\n} from './types';\n\nexport type RalphFixtures = { ralph: Ralph; ralphOptions: RalphOptions };\n\ntype InternalFixtures = {\n _ralphScreen: void;\n _ralphRecorder: Recorder | null;\n};\ntype WorkerFixtures = { _ralphConfigs: Map<string, Promise<ConfigSnapshot>> };\n\nexport const test: TestType<\n PlaywrightTestArgs & PlaywrightTestOptions & RalphFixtures,\n PlaywrightWorkerArgs & PlaywrightWorkerOptions\n> = base.extend<RalphFixtures & InternalFixtures, WorkerFixtures>({\n ralphOptions: [{}, { option: true }],\n _ralphConfigs: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use) => {\n await use(new Map());\n },\n { scope: 'worker' },\n ],\n _ralphScreen: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use, testInfo) => {\n // Screen selection chooses which flows run, so it applies in every mode.\n applyScreenSelection(testInfo);\n await use();\n },\n { auto: true },\n ],\n _ralphRecorder: [\n // _ralphScreen is specified here so that the playwright treat that it is\n // a dependency of this fixture.\n async ({ ralphOptions, _ralphConfigs, _ralphScreen }, use, testInfo) => {\n const mode = resolveMode(ralphOptions);\n if (!testInfo.tags.includes('@ralph') || mode === 'off') {\n await use(null);\n return;\n }\n\n // A sharded run's blob reports are merged, and uploaded by the Ralph\n // reporter, in `playwright merge-reports`.\n const reported =\n process.env[REPORTER_ENV] ||\n testInfo.config.reporter.some(([name]) => name === 'blob');\n if (mode === 'upload' && !reported) {\n throw new Error(\n \"Ralph uploads one merged graph per run from its reporter. Add ['@ralphralphai/playwright/reporter'] to `reporter` in playwright.config, or use the blob reporter and merge-reports when sharding.\",\n );\n }\n\n const root = testInfo.config.configFile\n ? path.dirname(testInfo.config.configFile)\n : process.cwd();\n\n const key = JSON.stringify({\n root,\n config: ralphOptions.config,\n configPath: ralphOptions.configPath,\n });\n\n let pending = _ralphConfigs.get(key);\n if (!pending) {\n pending = loadConfig(ralphOptions, root);\n _ralphConfigs.set(key, pending);\n }\n\n const recorder = new Recorder(\n ralphOptions,\n await pending,\n testInfo,\n mode,\n );\n\n try {\n await use(recorder);\n } finally {\n await recorder.finish();\n }\n },\n { auto: true, timeout: 30_000 },\n ],\n ralph: async ({ _ralphRecorder, ralphOptions }, use) => {\n const checkOptIn = () => {\n if (resolveMode(ralphOptions) !== 'off') {\n throw new Error('Opt this test into Ralph with the @ralph tag.');\n }\n };\n\n await use(\n _ralphRecorder ?? {\n recordNode: async () => {\n checkOptIn();\n },\n waitForCapture: async () => {},\n pause: () => {},\n resume: () => {},\n observe: () => {\n checkOptIn();\n },\n },\n );\n },\n // On the context rather than per page, so popups start out reduced too.\n contextOptions: async ({ contextOptions, ralphOptions }, use, testInfo) => {\n const recording =\n testInfo.tags.includes('@ralph') && resolveMode(ralphOptions) !== 'off';\n await use(\n recording && ralphOptions.reduceMotion\n ? { ...contextOptions, reducedMotion: 'reduce' }\n : contextOptions,\n );\n },\n context: async ({ context, _ralphRecorder }, use) => {\n _ralphRecorder?.observe(context);\n try {\n await use(context);\n } finally {\n // The base context fixture closes its pages next; pending recordings still need them alive.\n await _ralphRecorder?.stopContext(context);\n }\n },\n});\n"],"mappings":"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"}
@@ -0,0 +1,169 @@
1
+ const require_upload = require("./upload-QZreR5i6.cjs");
2
+ let node_path = require("node:path");
3
+ node_path = require_upload.__toESM(node_path, 1);
4
+ let node_fs_promises = require("node:fs/promises");
5
+ let node_crypto = require("node:crypto");
6
+ let _playwright_test_package_json = require("@playwright/test/package.json");
7
+ _playwright_test_package_json = require_upload.__toESM(_playwright_test_package_json, 1);
8
+ //#region src/reporter.ts
9
+ /**
10
+ * Merges every recorded test's raw graph into one for the whole run, writes it
11
+ * locally, and uploads it once when the mode is `upload`.
12
+ *
13
+ * Tests run in worker processes, so only a reporter, in the main process,
14
+ * sees them all. For a sharded run, it runs in `playwright merge-reports` over
15
+ * the shards' blob reports.
16
+ */
17
+ var RalphReporter = class {
18
+ options;
19
+ artifactId = (0, node_crypto.randomUUID)();
20
+ startedAt = (/* @__PURE__ */ new Date()).toISOString();
21
+ config;
22
+ /**
23
+ * By test id, so a retry replaces the attempt before it. Read as each test
24
+ * ends, and awaited in `onEnd`.
25
+ */
26
+ tests = /* @__PURE__ */ new Map();
27
+ constructor(options = {}) {
28
+ this.options = options;
29
+ process.env[require_upload.REPORTER_ENV] = "1";
30
+ if (require_upload.resolveMode(options) === "upload") require_upload.resolveUploadOptions(options);
31
+ }
32
+ onBegin(config) {
33
+ this.config = config;
34
+ }
35
+ onTestEnd(test, result) {
36
+ const collected = collect(result);
37
+ collected.catch(() => void 0);
38
+ this.tests.set(test.id, collected);
39
+ }
40
+ async onEnd() {
41
+ try {
42
+ const collected = (await Promise.all(this.tests.values())).filter((item) => item !== void 0);
43
+ if (collected.length === 0) return;
44
+ const { manifest, images } = this.merge(collected);
45
+ const screenshots = await Promise.all(images.map(async ({ source, ...image }) => ({
46
+ ...image,
47
+ bytes: typeof source === "string" ? await (0, node_fs_promises.readFile)(source) : source
48
+ })));
49
+ const nodeCount = manifest.rawGraphs.reduce((total, graph) => total + graph.nodes.length, 0);
50
+ const outputDir = await this.write(manifest, screenshots);
51
+ log(`Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`);
52
+ if (require_upload.resolveMode(this.options) === "upload" || collected.some(({ file }) => file.mode === "upload")) {
53
+ const receipt = await require_upload.uploadRawGraph(manifest, screenshots, require_upload.resolveUploadOptions(this.options));
54
+ await (0, node_fs_promises.writeFile)(node_path.default.join(outputDir, "upload_receipt.json"), JSON.stringify(receipt, null, 2) + "\n");
55
+ log(`Ralph: uploaded the run's raw graph. ${receipt.resultUrl ?? "Result id: " + receipt.resultId}`);
56
+ }
57
+ return;
58
+ } catch (error) {
59
+ console.error("Ralph: " + (error instanceof Error ? error.message : String(error)));
60
+ return { status: "failed" };
61
+ }
62
+ }
63
+ printsToStdio() {
64
+ return false;
65
+ }
66
+ /** Merges the tests into one manifest, with where to read their screenshots. */
67
+ merge(collected) {
68
+ const [first] = collected;
69
+ const same = (pick, what) => {
70
+ const expected = JSON.stringify(pick(first.file));
71
+ if (collected.some(({ file }) => JSON.stringify(pick(file)) !== expected)) throw new Error(`Every recorded test in a run must share one ${what}; merge them per run.`);
72
+ };
73
+ same((file) => file.config, "Ralph config");
74
+ same((file) => [file.runId, file.buildId], "runId and buildId");
75
+ const images = [];
76
+ const rawGraphs = collected.map(({ file, status, attachments }) => {
77
+ const nodes = file.rawGraph.nodes.map((node) => {
78
+ if (node.status !== "recorded") return node;
79
+ const source = attachments.get(node.screenshot.attachmentName);
80
+ if (source === void 0) throw new Error(`The screenshot of node ${node.nodeId} is missing from the test results.`);
81
+ images.push({
82
+ nodeId: node.nodeId,
83
+ contentType: node.screenshot.contentType,
84
+ source
85
+ });
86
+ return {
87
+ ...node,
88
+ screenshot: {
89
+ ...node.screenshot,
90
+ path: require_upload.bundleImagePath(node.nodeId, node.screenshot.contentType)
91
+ }
92
+ };
93
+ });
94
+ return {
95
+ ...file.rawGraph,
96
+ status,
97
+ nodes,
98
+ complete: file.rawGraph.complete && status === "passed"
99
+ };
100
+ }).sort((a, b) => a.project.localeCompare(b.project) || a.titlePath.join("\0").localeCompare(b.titlePath.join("\0")) || a.repeatEachIndex - b.repeatEachIndex);
101
+ return {
102
+ manifest: {
103
+ formatVersion: 1,
104
+ producer: {
105
+ name: "@ralphralphai/playwright",
106
+ version: require_upload.version,
107
+ playwrightVersion: _playwright_test_package_json.default.version
108
+ },
109
+ runId: first.file.runId,
110
+ buildId: first.file.buildId,
111
+ artifactId: this.artifactId,
112
+ shard: this.config?.shard ?? null,
113
+ startedAt: this.startedAt,
114
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
115
+ config: first.file.config,
116
+ rawGraphs,
117
+ complete: rawGraphs.every((graph) => graph.complete)
118
+ },
119
+ images
120
+ };
121
+ }
122
+ /** Writes the manifest and screenshots in the bundle's layout. */
123
+ async write(manifest, screenshots) {
124
+ const outputDir = node_path.default.resolve(this.options.outputDir ?? node_path.default.join(this.config?.projects[0]?.outputDir ?? node_path.default.join(this.config?.rootDir ?? process.cwd(), "test-results"), "ralph"));
125
+ await (0, node_fs_promises.rm)(outputDir, {
126
+ recursive: true,
127
+ force: true
128
+ });
129
+ const index = {
130
+ manifest,
131
+ screenshots: []
132
+ };
133
+ for (const { nodeId, contentType, bytes } of screenshots) {
134
+ const imagePath = require_upload.bundleImagePath(nodeId, contentType);
135
+ index.screenshots.push({
136
+ nodeId,
137
+ path: imagePath
138
+ });
139
+ await (0, node_fs_promises.mkdir)(node_path.default.dirname(node_path.default.join(outputDir, imagePath)), { recursive: true });
140
+ await (0, node_fs_promises.writeFile)(node_path.default.join(outputDir, imagePath), bytes);
141
+ }
142
+ await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
143
+ await (0, node_fs_promises.writeFile)(node_path.default.join(outputDir, require_upload.RAW_GRAPH_BUNDLE_INDEX), JSON.stringify(index, null, 2) + "\n");
144
+ return outputDir;
145
+ }
146
+ };
147
+ /** Reads a test's raw graph from its attachments; undefined if it recorded nothing. */
148
+ async function collect(result) {
149
+ const manifest = result.attachments.find((item) => item.name === require_upload.MANIFEST_ATTACHMENT);
150
+ if (!manifest || !manifest.body && !manifest.path) return;
151
+ const file = JSON.parse(manifest.body?.toString("utf8") ?? await (0, node_fs_promises.readFile)(manifest.path, "utf8"));
152
+ const attachments = /* @__PURE__ */ new Map();
153
+ for (const { name, path: attachmentPath, body } of result.attachments) {
154
+ const source = body ?? attachmentPath;
155
+ if (source !== void 0) attachments.set(name, source);
156
+ }
157
+ return {
158
+ file,
159
+ status: result.status,
160
+ attachments
161
+ };
162
+ }
163
+ function log(message) {
164
+ process.stdout.write(message + "\n");
165
+ }
166
+ //#endregion
167
+ module.exports = RalphReporter;
168
+
169
+ //# sourceMappingURL=reporter.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reporter.cjs","names":["randomUUID","REPORTER_ENV","resolveMode","readFile","uploadRawGraph","resolveUploadOptions","writeFile","path","bundleImagePath","playwrightPackage","rm","mkdir","RAW_GRAPH_BUNDLE_INDEX","MANIFEST_ATTACHMENT"],"sources":["../src/reporter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nimport packageInfo from '../package.json';\nimport { resolveMode } from './config';\nimport { MANIFEST_ATTACHMENT, REPORTER_ENV } from './recorder';\nimport type { RawGraphManifest, RawGraph, RawGraphFile } from './types';\nimport {\n RAW_GRAPH_BUNDLE_INDEX,\n 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":"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"}
@@ -0,0 +1 @@
1
+ export type * from './reporter.d.mts'
@@ -0,0 +1,54 @@
1
+ import { FullConfig, FullResult, Reporter, TestCase, TestResult } from "@playwright/test/reporter";
2
+ //#region src/reporter.d.ts
3
+ type RalphReporterOptions = {
4
+ /**
5
+ * `upload` uploads the merged raw graph. Defaults to `RALPH_MODE`, else
6
+ * `upload` when `RALPH_UPLOAD_KEY` is set. The run is also uploaded when any
7
+ * test ran in `upload` mode.
8
+ */
9
+ mode?: 'off' | 'local' | 'upload';
10
+ /** Your Ralph server. Defaults to `RALPH_API_URL`. */
11
+ apiUrl?: string;
12
+ /** The app the recordings belong to. Defaults to `RALPH_APP_ID`. */
13
+ appId?: string;
14
+ /** How long the upload may take, in milliseconds. Defaults to 120000. */
15
+ uploadTimeoutMs?: number;
16
+ /**
17
+ * Where the merged raw graph is written, laid out like the uploaded bundle.
18
+ * Defaults to `ralph` inside the first project's output directory.
19
+ */
20
+ outputDir?: string;
21
+ };
22
+ /**
23
+ * Merges every recorded test's raw graph into one for the whole run, writes it
24
+ * locally, and uploads it once when the mode is `upload`.
25
+ *
26
+ * Tests run in worker processes, so only a reporter, in the main process,
27
+ * sees them all. For a sharded run, it runs in `playwright merge-reports` over
28
+ * the shards' blob reports.
29
+ */
30
+ declare class RalphReporter implements Reporter {
31
+ private readonly options;
32
+ private readonly artifactId;
33
+ private readonly startedAt;
34
+ private config?;
35
+ /**
36
+ * By test id, so a retry replaces the attempt before it. Read as each test
37
+ * ends, and awaited in `onEnd`.
38
+ */
39
+ private readonly tests;
40
+ constructor(options?: RalphReporterOptions);
41
+ onBegin(config: FullConfig): void;
42
+ onTestEnd(test: TestCase, result: TestResult): void;
43
+ onEnd(): Promise<{
44
+ status?: FullResult['status'];
45
+ } | undefined>;
46
+ printsToStdio(): boolean;
47
+ /** Merges the tests into one manifest, with where to read their screenshots. */
48
+ private merge;
49
+ /** Writes the manifest and screenshots in the bundle's layout. */
50
+ private write;
51
+ }
52
+ //#endregion
53
+ export { RalphReporterOptions, RalphReporter as default };
54
+ //# sourceMappingURL=reporter.d.mts.map
@@ -0,0 +1,167 @@
1
+ import { a as uploadRawGraph, f as resolveMode, i as resolveUploadOptions, l as version, n as bundleImagePath, o as MANIFEST_ATTACHMENT, s as REPORTER_ENV, t as RAW_GRAPH_BUNDLE_INDEX } from "./upload-giSVxpun.mjs";
2
+ import path from "node:path";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import playwrightPackage from "@playwright/test/package.json" with { type: "json" };
6
+ //#region src/reporter.ts
7
+ /**
8
+ * Merges every recorded test's raw graph into one for the whole run, writes it
9
+ * locally, and uploads it once when the mode is `upload`.
10
+ *
11
+ * Tests run in worker processes, so only a reporter, in the main process,
12
+ * sees them all. For a sharded run, it runs in `playwright merge-reports` over
13
+ * the shards' blob reports.
14
+ */
15
+ var RalphReporter = class {
16
+ options;
17
+ artifactId = randomUUID();
18
+ startedAt = (/* @__PURE__ */ new Date()).toISOString();
19
+ config;
20
+ /**
21
+ * By test id, so a retry replaces the attempt before it. Read as each test
22
+ * ends, and awaited in `onEnd`.
23
+ */
24
+ tests = /* @__PURE__ */ new Map();
25
+ constructor(options = {}) {
26
+ this.options = options;
27
+ process.env[REPORTER_ENV] = "1";
28
+ if (resolveMode(options) === "upload") resolveUploadOptions(options);
29
+ }
30
+ onBegin(config) {
31
+ this.config = config;
32
+ }
33
+ onTestEnd(test, result) {
34
+ const collected = collect(result);
35
+ collected.catch(() => void 0);
36
+ this.tests.set(test.id, collected);
37
+ }
38
+ async onEnd() {
39
+ try {
40
+ const collected = (await Promise.all(this.tests.values())).filter((item) => item !== void 0);
41
+ if (collected.length === 0) return;
42
+ const { manifest, images } = this.merge(collected);
43
+ const screenshots = await Promise.all(images.map(async ({ source, ...image }) => ({
44
+ ...image,
45
+ bytes: typeof source === "string" ? await readFile(source) : source
46
+ })));
47
+ const nodeCount = manifest.rawGraphs.reduce((total, graph) => total + graph.nodes.length, 0);
48
+ const outputDir = await this.write(manifest, screenshots);
49
+ log(`Ralph: merged ${manifest.rawGraphs.length} test(s), ${nodeCount} node(s) into ${outputDir}`);
50
+ if (resolveMode(this.options) === "upload" || collected.some(({ file }) => file.mode === "upload")) {
51
+ const receipt = await uploadRawGraph(manifest, screenshots, resolveUploadOptions(this.options));
52
+ await writeFile(path.join(outputDir, "upload_receipt.json"), JSON.stringify(receipt, null, 2) + "\n");
53
+ log(`Ralph: uploaded the run's raw graph. ${receipt.resultUrl ?? "Result id: " + receipt.resultId}`);
54
+ }
55
+ return;
56
+ } catch (error) {
57
+ console.error("Ralph: " + (error instanceof Error ? error.message : String(error)));
58
+ return { status: "failed" };
59
+ }
60
+ }
61
+ printsToStdio() {
62
+ return false;
63
+ }
64
+ /** Merges the tests into one manifest, with where to read their screenshots. */
65
+ merge(collected) {
66
+ const [first] = collected;
67
+ const same = (pick, what) => {
68
+ const expected = JSON.stringify(pick(first.file));
69
+ if (collected.some(({ file }) => JSON.stringify(pick(file)) !== expected)) throw new Error(`Every recorded test in a run must share one ${what}; merge them per run.`);
70
+ };
71
+ same((file) => file.config, "Ralph config");
72
+ same((file) => [file.runId, file.buildId], "runId and buildId");
73
+ const images = [];
74
+ const rawGraphs = collected.map(({ file, status, attachments }) => {
75
+ const nodes = file.rawGraph.nodes.map((node) => {
76
+ if (node.status !== "recorded") return node;
77
+ const source = attachments.get(node.screenshot.attachmentName);
78
+ if (source === void 0) throw new Error(`The screenshot of node ${node.nodeId} is missing from the test results.`);
79
+ images.push({
80
+ nodeId: node.nodeId,
81
+ contentType: node.screenshot.contentType,
82
+ source
83
+ });
84
+ return {
85
+ ...node,
86
+ screenshot: {
87
+ ...node.screenshot,
88
+ path: bundleImagePath(node.nodeId, node.screenshot.contentType)
89
+ }
90
+ };
91
+ });
92
+ return {
93
+ ...file.rawGraph,
94
+ status,
95
+ nodes,
96
+ complete: file.rawGraph.complete && status === "passed"
97
+ };
98
+ }).sort((a, b) => a.project.localeCompare(b.project) || a.titlePath.join("\0").localeCompare(b.titlePath.join("\0")) || a.repeatEachIndex - b.repeatEachIndex);
99
+ return {
100
+ manifest: {
101
+ formatVersion: 1,
102
+ producer: {
103
+ name: "@ralphralphai/playwright",
104
+ version,
105
+ playwrightVersion: playwrightPackage.version
106
+ },
107
+ runId: first.file.runId,
108
+ buildId: first.file.buildId,
109
+ artifactId: this.artifactId,
110
+ shard: this.config?.shard ?? null,
111
+ startedAt: this.startedAt,
112
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
113
+ config: first.file.config,
114
+ rawGraphs,
115
+ complete: rawGraphs.every((graph) => graph.complete)
116
+ },
117
+ images
118
+ };
119
+ }
120
+ /** Writes the manifest and screenshots in the bundle's layout. */
121
+ async write(manifest, screenshots) {
122
+ const outputDir = path.resolve(this.options.outputDir ?? path.join(this.config?.projects[0]?.outputDir ?? path.join(this.config?.rootDir ?? process.cwd(), "test-results"), "ralph"));
123
+ await rm(outputDir, {
124
+ recursive: true,
125
+ force: true
126
+ });
127
+ const index = {
128
+ manifest,
129
+ screenshots: []
130
+ };
131
+ for (const { nodeId, contentType, bytes } of screenshots) {
132
+ const imagePath = bundleImagePath(nodeId, contentType);
133
+ index.screenshots.push({
134
+ nodeId,
135
+ path: imagePath
136
+ });
137
+ await mkdir(path.dirname(path.join(outputDir, imagePath)), { recursive: true });
138
+ await writeFile(path.join(outputDir, imagePath), bytes);
139
+ }
140
+ await mkdir(outputDir, { recursive: true });
141
+ await writeFile(path.join(outputDir, RAW_GRAPH_BUNDLE_INDEX), JSON.stringify(index, null, 2) + "\n");
142
+ return outputDir;
143
+ }
144
+ };
145
+ /** Reads a test's raw graph from its attachments; undefined if it recorded nothing. */
146
+ async function collect(result) {
147
+ const manifest = result.attachments.find((item) => item.name === MANIFEST_ATTACHMENT);
148
+ if (!manifest || !manifest.body && !manifest.path) return;
149
+ const file = JSON.parse(manifest.body?.toString("utf8") ?? await readFile(manifest.path, "utf8"));
150
+ const attachments = /* @__PURE__ */ new Map();
151
+ for (const { name, path: attachmentPath, body } of result.attachments) {
152
+ const source = body ?? attachmentPath;
153
+ if (source !== void 0) attachments.set(name, source);
154
+ }
155
+ return {
156
+ file,
157
+ status: result.status,
158
+ attachments
159
+ };
160
+ }
161
+ function log(message) {
162
+ process.stdout.write(message + "\n");
163
+ }
164
+ //#endregion
165
+ export { RalphReporter as default };
166
+
167
+ //# sourceMappingURL=reporter.mjs.map
@@ -0,0 +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":"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"}