@ralphralphai/playwright 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,15 +50,12 @@ export default defineConfig({
50
50
  });
51
51
  ```
52
52
 
53
- **4. Run:**
53
+ **4. Run with your upload key** (see [Uploading to Ralph](#uploading-to-ralph)):
54
54
 
55
55
  ```sh
56
- RALPH_MODE=local pnpm exec playwright test
56
+ RALPH_UPLOAD_KEY=… RALPH_APP_ID=… pnpm exec playwright test
57
57
  ```
58
58
 
59
- The merged graph for the run is written to `test-results/ralph/`. To send it to
60
- Ralph, see [Uploading to Ralph](#uploading-to-ralph).
61
-
62
59
  ## Tag your elements
63
60
 
64
61
  Add `data-track-id` to elements, using the same ids as your
@@ -161,27 +158,24 @@ await context.close();
161
158
 
162
159
  Set with `RALPH_MODE` or the `mode` option:
163
160
 
164
- | Mode | What happens |
165
- | -------- | --------------------------------------------------------------------------------------------------- |
166
- | `off` | Default. Nothing is recorded, and no config or credentials are needed. |
167
- | `local` | `@ralph` tests are recorded, and the reporter writes the merged graph locally. |
168
- | `upload` | As `local`, and the reporter uploads the graph once the run ends. Default when `RALPH_UPLOAD_KEY` is set. |
161
+ | Mode | What happens |
162
+ | -------- | ----------------------------------------------------------------------------------------------- |
163
+ | `off` | Default. Nothing is recorded, and no config or credentials are needed. |
164
+ | `upload` | `@ralph` tests are recorded, and the reporter uploads the run. Default when `RALPH_UPLOAD_KEY` is set. |
169
165
 
170
- Untagged tests are never recorded. In `local` or `upload` mode, calling
166
+ Untagged tests are never recorded. In `upload` mode, calling
171
167
  `ralph.recordNode` in an untagged test throws.
172
168
 
173
169
  ## Uploading to Ralph
174
170
 
175
- 1. Create an upload key for your project with `/api/project/key/create`, and
176
- keep it in your CI secrets.
177
- 2. Set these as environment variables, or `apiUrl` and `appId` as
171
+ 1. Create an upload key for your project in the project dashboard.
172
+ 2. Set these as environment variables, or `appId` as
178
173
  [reporter options](#reporter-options):
179
174
 
180
- | Variable | Value |
181
- | ------------------ | ----------------------------------------------- |
182
- | `RALPH_UPLOAD_KEY` | The upload key. Environment only. |
183
- | `RALPH_API_URL` | Your Ralph server. HTTPS, or HTTP on localhost. |
184
- | `RALPH_APP_ID` | The app the recordings belong to. |
175
+ | Variable | Value |
176
+ | ------------------ | -------------------------------------------------- |
177
+ | `RALPH_UPLOAD_KEY` | The upload key. Environment only. |
178
+ | `RALPH_APP_ID` | The app the recordings belong to. |
185
179
 
186
180
  3. Run:
187
181
 
@@ -189,16 +183,15 @@ Untagged tests are never recorded. In `local` or `upload` mode, calling
189
183
  RALPH_MODE=upload pnpm exec playwright test
190
184
  ```
191
185
 
192
- The reporter uploads the whole run once and prints the link:
186
+ The reporter uploads the whole run once. It prints the result link before the
187
+ upload starts, so you can open it straight away and watch the run upload and
188
+ then be processed:
193
189
 
194
190
  ```
195
- Ralph: uploaded the run's raw graph. https://dash.ralphralph.ai/uploads/0f6c6a2e-…
191
+ Ralph: uploading the run. Follow the upload and processing at https://dash.ralphralph.ai/uploads/0f6c6a2e-…
192
+ Ralph: uploaded the run. Ralph is processing it at https://dash.ralphralph.ai/uploads/0f6c6a2e-…
196
193
  ```
197
194
 
198
- - Retried tests contribute only their final attempt.
199
- - Every test in a run must use the same Ralph config, `runId`, and `buildId`.
200
- - If the upload fails, the run fails.
201
-
202
195
  ### Sharded runs
203
196
 
204
197
  Use the `blob` reporter on each shard, then merge with the Ralph reporter:
@@ -208,7 +201,7 @@ Use the `blob` reporter on each shard, then merge with the Ralph reporter:
208
201
  RALPH_MODE=upload pnpm exec playwright test --shard=1/4 --reporter blob
209
202
 
210
203
  # Once, after collecting every shard's blob-report/ into all-blob-reports/:
211
- RALPH_MODE=upload RALPH_UPLOAD_KEY=… RALPH_API_URL=… RALPH_APP_ID=… \
204
+ RALPH_MODE=upload RALPH_UPLOAD_KEY=… RALPH_APP_ID=… \
212
205
  pnpm exec playwright merge-reports --reporter @ralphralphai/playwright/reporter ./all-blob-reports
213
206
  ```
214
207
 
@@ -260,14 +253,14 @@ Set `ralphOptions` in `playwright.config.ts`, or per file with `test.use`:
260
253
  ```ts
261
254
  export default defineConfig<RalphFixtures>({
262
255
  use: {
263
- ralphOptions: { mode: 'local', buildId: process.env.GIT_SHA, settleMs: 300 },
256
+ ralphOptions: { buildId: process.env.GIT_SHA, settleMs: 300 },
264
257
  },
265
258
  });
266
259
  ```
267
260
 
268
261
  | Option | Default | What it does |
269
262
  | ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------- |
270
- | `mode` | `RALPH_MODE`, else `off` | `off`, `local`, or `upload`. See [Modes](#modes). |
263
+ | `mode` | `RALPH_MODE`, else `off` | `off` or `upload`. See [Modes](#modes). |
271
264
  | `configPath` | `ralph.jsonc` | Path to the config, relative to the Playwright config. |
272
265
  | `config` | | An inline config instead of a file (type: `WebRalphConfig`). |
273
266
  | `buildId` | `RALPH_BUILD_ID` | Your app's build, to line up recordings with its analytics. |
@@ -286,7 +279,6 @@ reporter: [['list'], ['@ralphralphai/playwright/reporter', { appId: 'my-app' }]]
286
279
  | Option | Default | What it does |
287
280
  | ----------------- | ------------------------------------------------- | ----------------------------------------------- |
288
281
  | `mode` | `RALPH_MODE`, else `upload` if `RALPH_UPLOAD_KEY` | `upload` uploads the run. |
289
- | `apiUrl` | `RALPH_API_URL` | Your Ralph server. |
290
282
  | `appId` | `RALPH_APP_ID` | The app the recordings belong to. |
291
283
  | `uploadTimeoutMs` | 120000 | Upload timeout. |
292
284
  | `outputDir` | `ralph/` in the first project's output directory | Where the merged graph is written. |
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_upload = require("./upload-QZreR5i6.cjs");
2
+ const require_upload = require("./upload-DxF1r2db.cjs");
3
3
  let node_path = require("node:path");
4
4
  node_path = require_upload.__toESM(node_path, 1);
5
5
  let _playwright_test = require("@playwright/test");
@@ -139,6 +139,7 @@ Object.defineProperty(exports, "mergeTests", {
139
139
  return _playwright_test.mergeTests;
140
140
  }
141
141
  });
142
+ exports.prepareArtifactUpload = require_upload.prepareArtifactUpload;
142
143
  exports.ralphProjects = ralphProjects;
143
144
  exports.test = test;
144
145
  exports.uploadRawGraph = require_upload.uploadRawGraph;
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["loadConfigSync","base","resolveMode","path","loadConfig","Recorder"],"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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"}
1
+ {"version":3,"file":"index.cjs","names":["loadConfigSync","base","resolveMode","path","loadConfig","Recorder"],"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 {\n createRawGraphBundle,\n prepareArtifactUpload,\n uploadRawGraph,\n} from './upload';\nexport type {\n PreparedUpload,\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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"}
package/dist/index.d.mts CHANGED
@@ -175,12 +175,17 @@ type RawGraphScreenshot = {
175
175
  contentType: Screenshot['contentType'];
176
176
  bytes: Uint8Array;
177
177
  };
178
+ /** An artifact the server reserved, which the upload and the manifest must name. */
179
+ type PreparedUpload = {
180
+ artifactId: string;
181
+ /** Where the result opens in the Ralph dashboard, when the server knows. */
182
+ resultUrl?: string;
183
+ };
184
+ /** The bundle is stored and queued; the result page shows it being processed. */
178
185
  type UploadReceipt = {
179
186
  result: 'ok';
180
- status: 'received';
187
+ status: 'queued';
181
188
  artifactId: string;
182
- resultId: string;
183
- /** Where the result opens in the Ralph dashboard, when the server knows. */
184
189
  resultUrl?: string;
185
190
  replayed: boolean;
186
191
  };
@@ -191,13 +196,18 @@ type RawGraphBundleIndex = {
191
196
  path: string;
192
197
  }[];
193
198
  };
199
+ /** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */
200
+ declare function createRawGraphBundle(manifest: RawGraphManifest, screenshots: readonly RawGraphScreenshot[]): Promise<Uint8Array>;
194
201
  /**
195
- * Packs the manifest and screenshots into a gzipped tar archive:
196
- *
197
- * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }
198
- * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
202
+ * Reserves an artifact for the run. Its `resultUrl` works from now on, so the
203
+ * run can be followed while it uploads and while Ralph processes it.
204
+ */
205
+ declare function prepareArtifactUpload(options: UploadOptions): Promise<PreparedUpload>;
206
+ /**
207
+ * Streams the run's bundle to the artifact `manifest.artifactId` names, which
208
+ * `prepareArtifactUpload` has to have reserved. Ralph processes it after this
209
+ * returns; the result page shows when it is ready.
199
210
  */
200
- declare function createRawGraphBundle(manifest: RawGraphManifest, screenshots: readonly RawGraphScreenshot[]): Promise<Uint8Array>;
201
211
  declare function uploadRawGraph(manifest: RawGraphManifest, screenshots: readonly RawGraphScreenshot[], options: UploadOptions): Promise<UploadReceipt>;
202
212
  //#endregion
203
213
  //#region src/recorder.d.ts
@@ -226,5 +236,5 @@ type RalphFixtures = {
226
236
  };
227
237
  declare const test: TestType<PlaywrightTestArgs & PlaywrightTestOptions & RalphFixtures, PlaywrightWorkerArgs & PlaywrightWorkerOptions>;
228
238
  //#endregion
229
- export { type ConfigSnapshot, MANIFEST_ATTACHMENT, type PageLayout, type Ralph, RalphFixtures, type RalphOptions, type RalphProjectOptions, type RalphReporterOptions, type RawGraph, type RawGraphBundleIndex, type RawGraphFile, type RawGraphManifest, type RawGraphScreenshot, type RawNode, type RawPage, type RecordNodeOptions, SCREEN_TAG_PREFIX, type TrackedElement, type UploadOptions, type UploadReceipt, type WebRalphConfig, createRawGraphBundle, expect, mergeTests, ralphProjects, test, uploadRawGraph };
239
+ export { type ConfigSnapshot, MANIFEST_ATTACHMENT, type PageLayout, type PreparedUpload, type Ralph, RalphFixtures, type RalphOptions, type RalphProjectOptions, type RalphReporterOptions, type RawGraph, type RawGraphBundleIndex, type RawGraphFile, type RawGraphManifest, type RawGraphScreenshot, type RawNode, type RawPage, type RecordNodeOptions, SCREEN_TAG_PREFIX, type TrackedElement, type UploadOptions, type UploadReceipt, type WebRalphConfig, createRawGraphBundle, expect, mergeTests, prepareArtifactUpload, ralphProjects, test, uploadRawGraph };
230
240
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as uploadRawGraph, c as Recorder, d as loadConfigSync, f as resolveMode, o as MANIFEST_ATTACHMENT, r as createRawGraphBundle, u as loadConfig } from "./upload-giSVxpun.mjs";
1
+ import { c as MANIFEST_ATTACHMENT, f as loadConfig, i as prepareArtifactUpload, m as resolveMode, p as loadConfigSync, r as createRawGraphBundle, s as uploadRawGraph, u as Recorder } from "./upload-DPYMUP9Y.mjs";
2
2
  import path from "node:path";
3
3
  import { expect, mergeTests, test as test$1 } from "@playwright/test";
4
4
  //#region src/screens.ts
@@ -122,6 +122,6 @@ const test = test$1.extend({
122
122
  }
123
123
  });
124
124
  //#endregion
125
- export { MANIFEST_ATTACHMENT, SCREEN_TAG_PREFIX, createRawGraphBundle, expect, mergeTests, ralphProjects, test, uploadRawGraph };
125
+ export { MANIFEST_ATTACHMENT, SCREEN_TAG_PREFIX, createRawGraphBundle, expect, mergeTests, prepareArtifactUpload, ralphProjects, test, uploadRawGraph };
126
126
 
127
127
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
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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"}
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 {\n createRawGraphBundle,\n prepareArtifactUpload,\n uploadRawGraph,\n} from './upload';\nexport type {\n PreparedUpload,\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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"}
package/dist/reporter.cjs CHANGED
@@ -1,4 +1,4 @@
1
- const require_upload = require("./upload-QZreR5i6.cjs");
1
+ const require_upload = require("./upload-DxF1r2db.cjs");
2
2
  let node_path = require("node:path");
3
3
  node_path = require_upload.__toESM(node_path, 1);
4
4
  let node_fs_promises = require("node:fs/promises");
@@ -16,7 +16,6 @@ _playwright_test_package_json = require_upload.__toESM(_playwright_test_package_
16
16
  */
17
17
  var RalphReporter = class {
18
18
  options;
19
- artifactId = (0, node_crypto.randomUUID)();
20
19
  startedAt = (/* @__PURE__ */ new Date()).toISOString();
21
20
  config;
22
21
  /**
@@ -41,18 +40,35 @@ var RalphReporter = class {
41
40
  try {
42
41
  const collected = (await Promise.all(this.tests.values())).filter((item) => item !== void 0);
43
42
  if (collected.length === 0) return;
44
- const { manifest, images } = this.merge(collected);
45
- const screenshots = await Promise.all(images.map(async ({ source, ...image }) => ({
43
+ const merged = this.merge(collected);
44
+ const screenshots = await Promise.all(merged.images.map(async ({ source, ...image }) => ({
46
45
  ...image,
47
46
  bytes: typeof source === "string" ? await (0, node_fs_promises.readFile)(source) : source
48
47
  })));
48
+ const uploading = require_upload.resolveMode(this.options) === "upload" || collected.some(({ file }) => file.mode === "upload");
49
+ let prepared;
50
+ if (uploading) {
51
+ try {
52
+ prepared = await require_upload.prepareArtifactUpload(require_upload.resolveUploadOptions(this.options));
53
+ } catch (error) {
54
+ const outputDir = await this.write(merged.manifest, screenshots);
55
+ throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
56
+ }
57
+ log(`Ralph: uploading the run. Follow the upload and processing at ${require_upload.resultLink(prepared)}`);
58
+ }
59
+ const manifest = prepared === void 0 ? merged.manifest : {
60
+ ...merged.manifest,
61
+ artifactId: prepared.artifactId
62
+ };
49
63
  const nodeCount = manifest.rawGraphs.reduce((total, graph) => total + graph.nodes.length, 0);
50
64
  const outputDir = await this.write(manifest, screenshots);
51
65
  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));
66
+ if (prepared !== void 0) {
67
+ const receipt = await require_upload.uploadRawGraph(manifest, screenshots, require_upload.resolveUploadOptions(this.options)).catch((error) => {
68
+ throw new Error(`${error instanceof Error ? error.message : String(error)} The recording was saved to ${outputDir}.`, { cause: error });
69
+ });
54
70
  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}`);
71
+ log(`Ralph: uploaded the run. Ralph is processing it at ${require_upload.resultLink(receipt)}`);
56
72
  }
57
73
  return;
58
74
  } catch (error) {
@@ -108,7 +124,7 @@ var RalphReporter = class {
108
124
  },
109
125
  runId: first.file.runId,
110
126
  buildId: first.file.buildId,
111
- artifactId: this.artifactId,
127
+ artifactId: (0, node_crypto.randomUUID)(),
112
128
  shard: this.config?.shard ?? null,
113
129
  startedAt: this.startedAt,
114
130
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1 +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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"}
1
+ {"version":3,"file":"reporter.cjs","names":["REPORTER_ENV","resolveMode","readFile","prepareArtifactUpload","resolveUploadOptions","resultLink","uploadRawGraph","writeFile","path","bundleImagePath","playwrightPackage","randomUUID","rm","mkdir","RAW_GRAPH_BUNDLE_INDEX","MANIFEST_ATTACHMENT"],"sources":["../src/reporter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nimport packageInfo from '../package.json';\nimport { resolveMode } from './config';\nimport { MANIFEST_ATTACHMENT, REPORTER_ENV } from './recorder';\nimport type { RawGraphManifest, RawGraph, RawGraphFile } from './types';\nimport {\n RAW_GRAPH_BUNDLE_INDEX,\n prepareArtifactUpload,\n 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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"}
@@ -7,7 +7,7 @@ type RalphReporterOptions = {
7
7
  * test ran in `upload` mode.
8
8
  */
9
9
  mode?: 'off' | 'local' | 'upload';
10
- /** Your Ralph server. Defaults to `RALPH_API_URL`. */
10
+ /** Your Ralph server. Defaults to `RALPH_API_URL`, else `https://api.ralphralph.ai`. */
11
11
  apiUrl?: string;
12
12
  /** The app the recordings belong to. Defaults to `RALPH_APP_ID`. */
13
13
  appId?: string;
@@ -29,7 +29,6 @@ type RalphReporterOptions = {
29
29
  */
30
30
  declare class RalphReporter implements Reporter {
31
31
  private readonly options;
32
- private readonly artifactId;
33
32
  private readonly startedAt;
34
33
  private config?;
35
34
  /**
package/dist/reporter.mjs CHANGED
@@ -1,4 +1,4 @@
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";
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-DPYMUP9Y.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 { manifest, images } = this.merge(collected);
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 (resolveMode(this.options) === "upload" || collected.some(({ file }) => file.mode === "upload")) {
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's raw graph. ${receipt.resultUrl ?? "Result id: " + receipt.resultId}`);
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: this.artifactId,
125
+ artifactId: randomUUID(),
110
126
  shard: this.config?.shard ?? null,
111
127
  startedAt: this.startedAt,
112
128
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -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":"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"}
1
+ {"version":3,"file":"reporter.mjs","names":["packageInfo.version"],"sources":["../src/reporter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\nimport type {\n FullConfig,\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from '@playwright/test/reporter';\n\nimport packageInfo from '../package.json';\nimport { resolveMode } from './config';\nimport { MANIFEST_ATTACHMENT, REPORTER_ENV } from './recorder';\nimport type { RawGraphManifest, RawGraph, RawGraphFile } from './types';\nimport {\n RAW_GRAPH_BUNDLE_INDEX,\n prepareArtifactUpload,\n 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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"}