@ralphralphai/playwright 1.4.0 → 1.6.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
@@ -86,28 +86,32 @@ ralphOptions: {
86
86
  },
87
87
  ```
88
88
 
89
+ Screenshots are WebP. A page taller or wider than 16,383 px, the most WebP can
90
+ hold, is recorded up to that size, and what lies beyond it has no screenshot.
91
+
89
92
  CSS animations and transitions are frozen for the screenshot. For animations
90
93
  driven from JavaScript, set `reduceMotion` if your app honors
91
94
  `prefers-reduced-motion`.
92
95
 
93
- ### Explicitly, for states at the same URL
96
+ ### Explicitly, once the page is ready
94
97
 
95
- For dialogs, menus, and tabs, call `ralph.recordNode` after your assertions:
98
+ A capture taken when the URL or the declared conditions change can catch the
99
+ page mid-transition. Call `ralph.recordNode` after your assertions to retake
100
+ the screenshot of the node just recorded:
96
101
 
97
102
  ```ts
98
- test('shipping options', { tag: '@ralph' }, async ({ page, ralph }) => {
103
+ test('cart', { tag: '@ralph' }, async ({ page, ralph }) => {
99
104
  await page.goto('https://example.com/cart');
100
- await page.getByRole('button', { name: 'Shipping options' }).click();
101
- await expect(page.getByRole('dialog')).toBeVisible();
105
+ await expect(page.getByRole('list', { name: 'Items' })).toBeVisible();
102
106
 
103
- await ralph.recordNode(page, {
104
- state: 'shipping-options-open', // a label for this state
105
- url: '/checkout/shipping-options', // optional: the URL to record it under
106
- });
107
+ await ralph.recordNode(page);
107
108
  });
108
109
  ```
109
110
 
110
- `url` only changes what is recorded; the browser doesn't navigate.
111
+ Each call replaces the screenshot, so the last one wins. A dialog at the same
112
+ URL is the same node, so recording it would replace the page's screenshot: give
113
+ it its own node by declaring a page tag for it (below), since that is what
114
+ production events carry.
111
115
 
112
116
  ### Node conditions, for pages that render differently at one URL
113
117
 
@@ -186,7 +190,9 @@ Set with `RALPH_MODE` or the `mode` option:
186
190
  | `upload` | `@ralph` tests are recorded, and the reporter uploads the run. Default when `RALPH_UPLOAD_KEY` is set. |
187
191
 
188
192
  Untagged tests are never recorded. In `upload` mode, calling
189
- `ralph.recordNode` in an untagged test throws.
193
+ `ralph.recordNode`, `ralph.setNodeConditions`, or `ralph.observe` in an
194
+ untagged test throws. Set `ignoreUntaggedCalls` to make them do nothing
195
+ instead, for helpers shared between tagged and untagged tests.
190
196
 
191
197
  ## Uploading to Ralph
192
198
 
@@ -292,6 +298,7 @@ export default defineConfig<RalphFixtures>({
292
298
  | `ready` | | `(page, signal) => Promise<void>`. Waits for your app before an automatic recording. Honor `signal`. |
293
299
  | `reduceMotion` | `false` | Emulates `prefers-reduced-motion: reduce` and waits for finite animations before recording. |
294
300
  | `strictNodeTags` | `false` | Fails the test when the app reports a tag outside what `ralph.setNodeConditions` declared. |
301
+ | `ignoreUntaggedCalls` | `false` | Makes `recordNode`, `setNodeConditions`, and `observe` no-ops in untagged tests instead of throwing. |
295
302
 
296
303
  ## Reporter options
297
304
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_upload = require("./upload-r8vvt1pE.cjs");
2
+ const require_upload = require("./upload-Cs6oMofL.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");
@@ -94,7 +94,7 @@ const test = _playwright_test.test.extend({
94
94
  }],
95
95
  ralph: async ({ _ralphRecorder, ralphOptions }, use) => {
96
96
  const checkOptIn = () => {
97
- if (require_upload.resolveMode(ralphOptions) !== "off") throw new Error("Opt this test into Ralph with the @ralph tag.");
97
+ if (require_upload.resolveMode(ralphOptions) !== "off" && !ralphOptions.ignoreUntaggedCalls) throw new Error("Opt this test into Ralph with the @ralph tag.");
98
98
  };
99
99
  await use(_ralphRecorder ?? {
100
100
  recordNode: async () => {
@@ -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 {\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 ConditionsDeclaration,\n NodeConditions,\n RecordedTags,\n TagConditions,\n TagDeclaration,\n TagValue,\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 setNodeConditions: async () => {\n checkOptIn();\n return async () => {};\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 ConditionsDeclaration,\n NodeConditions,\n RecordedTags,\n TagConditions,\n TagDeclaration,\n TagValue,\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 (\n resolveMode(ralphOptions) !== 'off' &&\n !ralphOptions.ignoreUntaggedCalls\n ) {\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 setNodeConditions: async () => {\n checkOptIn();\n return async () => {};\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
@@ -17,10 +17,12 @@ type RalphOptions = {
17
17
  * declared for it. Otherwise the mismatch is only listed on the node.
18
18
  */
19
19
  strictNodeTags?: boolean;
20
- };
21
- type RecordNodeOptions = {
22
- state?: string;
23
- url?: string;
20
+ /**
21
+ * Make `recordNode`, `setNodeConditions`, and `observe` no-ops in tests
22
+ * without the `@ralph` tag, instead of throwing, so helpers shared with
23
+ * untagged tests can call them.
24
+ */
25
+ ignoreUntaggedCalls?: boolean;
24
26
  };
25
27
  /** A `number` must be an integer, because values match by their JSON spelling and a float can be spelled more than one way. */
26
28
  type TagValue = boolean | number | string;
@@ -42,7 +44,12 @@ type RecordedTags = {
42
44
  pageTags: Record<string, TagValue>;
43
45
  };
44
46
  type Ralph = {
45
- recordNode(page: Page, options?: RecordNodeOptions): Promise<void>;
47
+ /**
48
+ * Takes the node's screenshot now, replacing the one taken automatically
49
+ * when the URL or the declared conditions changed, which can catch the page
50
+ * mid-transition. Call it once the page shows what the node stands for.
51
+ */
52
+ recordNode(page: Page): Promise<void>;
46
53
  /**
47
54
  * Declares which visitor and page tags the node on screen stands for,
48
55
  * merged over earlier declarations until the URL changes, and records the
@@ -86,7 +93,6 @@ type RawNode = {
86
93
  actualUrl: string;
87
94
  url: string;
88
95
  trigger: 'url-change' | 'condition-change' | 'explicit';
89
- state?: string;
90
96
  /** What the node stands for, as the test declared it. */
91
97
  conditions?: NodeConditions;
92
98
  /** What the app's tracker had in effect, absent when the page runs none. */
@@ -273,5 +279,5 @@ type RalphFixtures = {
273
279
  };
274
280
  declare const test: TestType<PlaywrightTestArgs & PlaywrightTestOptions & RalphFixtures, PlaywrightWorkerArgs & PlaywrightWorkerOptions>;
275
281
  //#endregion
276
- export { type ConditionsDeclaration, type ConfigSnapshot, MANIFEST_ATTACHMENT, type NodeConditions, 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, type RecordedTags, SCREEN_TAG_PREFIX, type TagConditions, type TagDeclaration, type TagValue, type TrackedElement, type UploadOptions, type UploadReceipt, type WebRalphConfig, createRawGraphBundle, expect, mergeTests, prepareArtifactUpload, ralphProjects, test, uploadRawGraph };
282
+ export { type ConditionsDeclaration, type ConfigSnapshot, MANIFEST_ATTACHMENT, type NodeConditions, 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 RecordedTags, SCREEN_TAG_PREFIX, type TagConditions, type TagDeclaration, type TagValue, type TrackedElement, type UploadOptions, type UploadReceipt, type WebRalphConfig, createRawGraphBundle, expect, mergeTests, prepareArtifactUpload, ralphProjects, test, uploadRawGraph };
277
283
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
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-CGeeQU3e.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-BENmMj46.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
@@ -92,7 +92,7 @@ const test = test$1.extend({
92
92
  }],
93
93
  ralph: async ({ _ralphRecorder, ralphOptions }, use) => {
94
94
  const checkOptIn = () => {
95
- if (resolveMode(ralphOptions) !== "off") throw new Error("Opt this test into Ralph with the @ralph tag.");
95
+ if (resolveMode(ralphOptions) !== "off" && !ralphOptions.ignoreUntaggedCalls) throw new Error("Opt this test into Ralph with the @ralph tag.");
96
96
  };
97
97
  await use(_ralphRecorder ?? {
98
98
  recordNode: async () => {
@@ -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 {\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 ConditionsDeclaration,\n NodeConditions,\n RecordedTags,\n TagConditions,\n TagDeclaration,\n TagValue,\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 setNodeConditions: async () => {\n checkOptIn();\n return async () => {};\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 ConditionsDeclaration,\n NodeConditions,\n RecordedTags,\n TagConditions,\n TagDeclaration,\n TagValue,\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 (\n resolveMode(ralphOptions) !== 'off' &&\n !ralphOptions.ignoreUntaggedCalls\n ) {\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 setNodeConditions: async () => {\n checkOptIn();\n return async () => {};\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-r8vvt1pE.cjs");
1
+ const require_upload = require("./upload-Cs6oMofL.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");
package/dist/reporter.mjs CHANGED
@@ -1,4 +1,4 @@
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-CGeeQU3e.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-BENmMj46.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";
@@ -45,7 +45,7 @@ function snapshot(value) {
45
45
  }
46
46
  //#endregion
47
47
  //#region package.json
48
- var version = "1.4.0";
48
+ var version = "1.6.0";
49
49
  //#endregion
50
50
  //#region src/animations.ts
51
51
  /**
@@ -127,6 +127,7 @@ function nodeKey(conditions) {
127
127
  * on real sites, which are mostly photographic content.
128
128
  */
129
129
  const WEBP_QUALITY = 80;
130
+ const WEBP_MAX_DIMENSION = 16383;
130
131
  async function takeScreenshot(page, deadline) {
131
132
  const options = {
132
133
  fullPage: true,
@@ -144,6 +145,29 @@ async function takeScreenshot(page, deadline) {
144
145
  contentType: "image/webp",
145
146
  ...webpSize(webp)
146
147
  };
148
+ const documentSize = await page.evaluate(() => {
149
+ return {
150
+ width: document.documentElement.scrollWidth,
151
+ height: document.documentElement.scrollHeight
152
+ };
153
+ });
154
+ const clipped = await page.screenshot({
155
+ ...options,
156
+ type: "webp",
157
+ quality: WEBP_QUALITY,
158
+ clip: {
159
+ x: 0,
160
+ y: 0,
161
+ width: Math.min(documentSize.width, WEBP_MAX_DIMENSION),
162
+ height: Math.min(documentSize.height, WEBP_MAX_DIMENSION)
163
+ },
164
+ timeout: Math.max(1, deadline - Date.now())
165
+ });
166
+ if (clipped.length > 0) return {
167
+ bytes: clipped,
168
+ contentType: "image/webp",
169
+ ...webpSize(clipped)
170
+ };
147
171
  const png = await page.screenshot({
148
172
  ...options,
149
173
  type: "png",
@@ -285,13 +309,12 @@ var Recorder = class {
285
309
  context.on("page", onPage);
286
310
  for (const page of context.pages()) onPage(page);
287
311
  }
288
- /** Records the page as it is now, e.g. an open dialog at the same URL. */
289
- async recordNode(page, options = {}) {
312
+ async recordNode(page) {
290
313
  this.observe(page.context());
291
314
  const state = this.pages.get(page);
292
315
  if (!state) throw new Error("Cannot record a closed browser page.");
293
316
  if (this.paused) return;
294
- await this.enqueueCreateNodeRequest(state, "explicit", options);
317
+ await this.enqueueCreateNodeRequest(state, "explicit");
295
318
  }
296
319
  async setNodeConditions(page, declaration) {
297
320
  this.observe(page.context());
@@ -380,10 +403,10 @@ var Recorder = class {
380
403
  state.generation++;
381
404
  state.lastUrl = url;
382
405
  state.declared.clear();
383
- if (!this.paused && isHttpUrl(url)) this.enqueueCreateNodeRequest(state, "url-change", {}).catch(() => void 0);
406
+ if (!this.paused && isHttpUrl(url)) this.enqueueCreateNodeRequest(state, "url-change").catch(() => void 0);
384
407
  }
385
408
  async recordConditionChange(state) {
386
- if (!this.paused && isHttpUrl(state.page.url())) await this.enqueueCreateNodeRequest(state, "condition-change", {});
409
+ if (!this.paused && isHttpUrl(state.page.url())) await this.enqueueCreateNodeRequest(state, "condition-change");
387
410
  }
388
411
  declaredConditions(state) {
389
412
  return mergeConditions([...state.declared.values()]);
@@ -392,19 +415,17 @@ var Recorder = class {
392
415
  return page.evaluate(() => window.__ralphTags?.()).catch(() => void 0);
393
416
  }
394
417
  /** Queues a recording behind the tab's earlier ones. */
395
- enqueueCreateNodeRequest(state, trigger, options) {
396
- const actualUrl = state.page.url();
397
- const url = options.url === void 0 ? actualUrl : new URL(options.url, actualUrl).href;
398
- if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) throw new Error("Ralph recordings require HTTP or HTTPS page and recorded URLs.");
418
+ enqueueCreateNodeRequest(state, trigger) {
419
+ const url = state.page.url();
420
+ if (!isHttpUrl(url)) throw new Error("Ralph recordings require an HTTP or HTTPS page.");
399
421
  const request = {
400
422
  nodeId: randomUUID(),
401
423
  pageId: state.info.pageId,
402
424
  sequence: state.sequence++,
403
425
  requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
404
- actualUrl,
426
+ actualUrl: url,
405
427
  url,
406
- trigger,
407
- ...options.state === void 0 ? {} : { state: options.state }
428
+ trigger
408
429
  };
409
430
  this.requestedAtMs.set(request.nodeId, Date.now());
410
431
  this.lastRequestedNodeId = request.nodeId;
@@ -435,7 +456,8 @@ var Recorder = class {
435
456
  const reported = await wait(this.reportedTags(page));
436
457
  const mismatches = mismatchesOf(conditions, reported);
437
458
  const node = nodeKey(conditions);
438
- if (request.trigger === "condition-change" && state.lastRecorded?.url === request.url && state.lastRecorded.node === node) return;
459
+ const sameNode = state.lastRecorded?.url === request.url && state.lastRecorded.node === node;
460
+ if (sameNode && request.trigger !== "explicit") return;
439
461
  if (reported === void 0 && compactConditions(conditions) !== void 0 && !warnedNoTracker) {
440
462
  warnedNoTracker = true;
441
463
  console.warn("Ralph: this page runs no tracker, so the node conditions the test declares cannot be checked against the tags the app reports. Production events must carry the same tags, or these nodes will match no visitors.");
@@ -451,7 +473,7 @@ var Recorder = class {
451
473
  check();
452
474
  const after = await wait(readLayout(page));
453
475
  check();
454
- this.nodes.push({
476
+ const recorded = {
455
477
  ...request,
456
478
  ...withConditions(conditions),
457
479
  ...reported === void 0 ? {} : { reportedTags: reported },
@@ -461,11 +483,24 @@ var Recorder = class {
461
483
  layout: before,
462
484
  geometryStable: JSON.stringify(before) === JSON.stringify(after),
463
485
  screenshot: await this.saveScreenshot(request.nodeId, image)
464
- });
465
- state.lastRecorded = {
466
- url: request.url,
467
- node
468
486
  };
487
+ const retaken = sameNode ? this.nodes.findIndex(({ nodeId }) => nodeId === state.lastRecorded?.nodeId) : -1;
488
+ if (retaken === -1) {
489
+ this.nodes.push(recorded);
490
+ state.lastRecorded = {
491
+ url: request.url,
492
+ node,
493
+ nodeId: request.nodeId
494
+ };
495
+ } else {
496
+ const { nodeId, sequence, requestedAt } = this.nodes[retaken];
497
+ this.nodes[retaken] = {
498
+ ...recorded,
499
+ nodeId,
500
+ sequence,
501
+ requestedAt
502
+ };
503
+ }
469
504
  } catch (error) {
470
505
  this.nodes.push({
471
506
  ...request,
@@ -801,4 +836,4 @@ async function responseDetail(response) {
801
836
  //#endregion
802
837
  export { resolveUploadOptions as a, MANIFEST_ATTACHMENT as c, version as d, loadConfig as f, prepareArtifactUpload as i, REPORTER_ENV as l, resolveMode as m, bundleImagePath as n, resultLink as o, loadConfigSync as p, createRawGraphBundle as r, uploadRawGraph as s, RAW_GRAPH_BUNDLE_INDEX as t, Recorder as u };
803
838
 
804
- //# sourceMappingURL=upload-CGeeQU3e.mjs.map
839
+ //# sourceMappingURL=upload-BENmMj46.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-BENmMj46.mjs","names":["delay","packageInfo.version"],"sources":["../src/config.ts","../package.json","../src/animations.ts","../src/conditions.ts","../src/image.ts","../src/layout.ts","../src/recorder.ts","../src/upload.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 { errors, type Page } from '@playwright/test';\n\n/**\n * Waits until no finite CSS animation, CSS transition, or Web Animation is\n * running. Infinite ones (spinners, shimmers) never finish, so they are\n * ignored. Animations driven by `requestAnimationFrame` are invisible here.\n *\n * Stops waiting after `timeoutMs` without failing; the screenshot then jumps\n * whatever is left to its end state.\n */\nexport async function waitForAnimations(\n page: Page,\n timeoutMs: number,\n): Promise<void> {\n try {\n await page.waitForFunction(\n () =>\n document\n .getAnimations()\n .every(\n (animation) =>\n animation.playState !== 'running' ||\n animation.playbackRate === 0 ||\n !Number.isFinite(animation.effect?.getComputedTiming().endTime),\n ),\n undefined,\n // Polled every frame: animations often start a frame or two late.\n { polling: 'raf', timeout: Math.max(1, timeoutMs) },\n );\n } catch (error) {\n if (!(error instanceof errors.TimeoutError)) {\n throw error;\n }\n }\n}\n","import type {\n ConditionsDeclaration,\n NodeConditions,\n RecordedTags,\n TagConditions,\n TagDeclaration,\n} from './types';\n\nconst declared = (declaration: TagDeclaration = {}): TagConditions => {\n const conditions: TagConditions = {};\n for (const [key, value] of Object.entries(declaration)) {\n if (value === undefined) {\n continue;\n }\n const values = Array.isArray(value) ? value : [value];\n const fractional = values.find(\n (item) => typeof item === 'number' && !Number.isSafeInteger(item),\n );\n if (fractional !== undefined) {\n throw new Error(\n `Tag \"${key}\" is declared as ${fractional}: a tag number must be an integer, since the tracker drops any other.`,\n );\n }\n conditions[key] = [...new Set(values.map((item) => item ?? null))];\n }\n return conditions;\n};\n\n/** A declaration as sets: a scalar is a one-element set, `undefined` is `null`. */\nexport function declaredConditions(\n declaration: ConditionsDeclaration,\n): NodeConditions {\n return {\n visitorTags: declared(declaration.visitorTags),\n pageTags: declared(declaration.pageTags),\n };\n}\n\n/** Later declarations win per key, as later `tagPage` calls do in the app. */\nexport function mergeConditions(layers: NodeConditions[]): NodeConditions {\n return {\n visitorTags: Object.assign({}, ...layers.map((layer) => layer.visitorTags)),\n pageTags: Object.assign({}, ...layers.map((layer) => layer.pageTags)),\n };\n}\n\n/**\n * Declared keys the app's tracker disagrees with, as `visitorTags.plan`. A\n * node's conditions come from the test alone, so this is the only check that\n * production events will carry the tags its conditions name: a key the app\n * never sets, or sets under another name, leaves the node without traffic.\n */\nexport function mismatchesOf(\n declared: NodeConditions,\n reported: RecordedTags | undefined,\n): string[] {\n if (reported === undefined) {\n return [];\n }\n const mismatches: string[] = [];\n for (const kind of ['visitorTags', 'pageTags'] as const) {\n for (const [key, allowed] of Object.entries(declared[kind] ?? {})) {\n if (!allowed.includes(reported[kind][key] ?? null)) {\n mismatches.push(`${kind}.${key}`);\n }\n }\n }\n return mismatches.sort();\n}\n\n/** Absent kinds are left out, so an unconditional node has no conditions. */\nexport function compactConditions(\n conditions: NodeConditions,\n): NodeConditions | undefined {\n const compact: NodeConditions = {};\n for (const kind of ['visitorTags', 'pageTags'] as const) {\n if (Object.keys(conditions[kind] ?? {}).length > 0) {\n compact[kind] = conditions[kind];\n }\n }\n return Object.keys(compact).length === 0 ? undefined : compact;\n}\n\n/**\n * The node a state belongs to, as a string, whatever the order of keys or\n * values. Equal keys are one node, so a tag change that leaves this alone\n * records nothing.\n */\nexport function nodeKey(conditions: NodeConditions): string {\n const sorted = (tags: TagConditions = {}) =>\n Object.entries(tags)\n .map(\n ([key, values]) =>\n [key, values.map((value) => JSON.stringify(value)).sort()] as const,\n )\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n return JSON.stringify([\n sorted(conditions.visitorTags),\n sorted(conditions.pageTags),\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;\nconst WEBP_MAX_DIMENSION = 16_383;\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 // Finite animations are jumped to their end state and infinite ones paused,\n // only while the screenshot is taken.\n const options = {\n fullPage: true,\n scale: 'css',\n animations: 'disabled',\n } 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 if (webp.length > 0) {\n return { bytes: webp, contentType: 'image/webp', ...webpSize(webp) };\n }\n\n // WebP stores each dimension in 14 bits, so a page over 16383px has no valid\n // encoding, and Chromium signals that with an empty buffer rather than an\n // error. The page is clipped to what fits rather than scaled down, because\n // the layout's boxes and the viewer's clicks are CSS pixels from the page's\n // top left, and a scaled screenshot would misplace every one of them. What\n // lies past the clip has no image.\n const documentSize = await page.evaluate(() => {\n return {\n width: document.documentElement.scrollWidth,\n height: document.documentElement.scrollHeight,\n };\n });\n const clipped = await page.screenshot({\n ...options,\n type: 'webp',\n quality: WEBP_QUALITY,\n clip: {\n x: 0,\n y: 0,\n width: Math.min(documentSize.width, WEBP_MAX_DIMENSION),\n height: Math.min(documentSize.height, WEBP_MAX_DIMENSION),\n },\n timeout: Math.max(1, deadline - Date.now()),\n });\n if (clipped.length > 0) {\n return { bytes: clipped, contentType: 'image/webp', ...webpSize(clipped) };\n }\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 { randomUUID } from 'node:crypto';\nimport { mkdir, 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 { waitForAnimations } from './animations';\nimport {\n compactConditions,\n declaredConditions,\n mergeConditions,\n mismatchesOf,\n nodeKey,\n} from './conditions';\nimport { imageExtension, takeScreenshot, type Screenshot } from './image';\nimport { readLayout } from './layout';\nimport type {\n RawNode,\n RawPage,\n RawGraphFile,\n ConfigSnapshot,\n ConditionsDeclaration,\n NodeConditions,\n RecordedTags,\n Ralph,\n RalphOptions,\n} from './types';\n\n/** The attachment holding a test's `RawGraphFile`, which the reporter merges. */\nexport const MANIFEST_ATTACHMENT = 'ralph-raw-graph';\n\n/**\n * Set by the reporter in the main process before any worker starts, so the\n * fixtures can refuse to upload without it.\n */\nexport const REPORTER_ENV = 'RALPH_PLAYWRIGHT_REPORTER';\n\n/** Prefix of the attachment holding each recorded node's screenshot; the node id follows. */\nexport const SCREENSHOT_ATTACHMENT_PREFIX = 'ralph-screenshot-';\n\n/** Once per worker: a run without the tracker says so once, not per node. */\nlet warnedNoTracker = false;\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 = RawPage;\n\ntype CreateNodeRequest = 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 /**\n * The test's `setNodeConditions` declarations for the current URL, one layer per call\n * so each `untag` removes its own. Cleared when the URL changes.\n */\n declared: Map<symbol, NodeConditions>;\n\n /** The last node recorded here, so a declaration that leaves the page on it records nothing. */\n lastRecorded?: { url: string; node: string; nodeId: string };\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 /**\n * For a tab first used while recording was paused: the last node requested\n * before the pause. Its first node links here when neither the tab nor its\n * opener has an earlier one. Popups inherit it from their opener.\n */\n pauseAnchor?: string;\n\n onNavigation: (frame: Frame) => void;\n};\n\nexport class Recorder implements Ralph {\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, with its page listener, kept so\n * `stopContext` can remove the exact listener `observe` added.\n */\n private readonly contexts = new Map<BrowserContext, (page: Page) => void>();\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 /** Set by `pause`; while set, no new nodes are requested. */\n private paused = false;\n\n /** The most recently requested node, the anchor for tabs used while paused. */\n private lastRequestedNodeId?: string;\n\n constructor(\n private readonly options: RalphOptions,\n private readonly config: ConfigSnapshot,\n private readonly testInfo: TestInfo,\n private readonly mode: RawGraphFile['mode'],\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 onPage = (page: Page) => this.watchPage(page);\n this.contexts.set(context, onPage);\n\n context.on('page', onPage);\n for (const page of context.pages()) {\n onPage(page);\n }\n }\n\n async recordNode(page: Page): 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\n if (this.paused) {\n return;\n }\n\n await this.enqueueCreateNodeRequest(state, 'explicit');\n }\n\n async setNodeConditions(\n page: Page,\n declaration: ConditionsDeclaration,\n ): Promise<() => Promise<void>> {\n this.observe(page.context());\n const state = this.pages.get(page);\n if (!state) {\n throw new Error('Cannot tag a closed browser page.');\n }\n\n const key = Symbol();\n state.declared.set(key, declaredConditions(declaration));\n await this.recordConditionChange(state);\n\n return async () => {\n if (state.declared.delete(key)) {\n await this.recordConditionChange(state);\n }\n };\n }\n\n /**\n * Stops requesting nodes, automatic or explicit, until `resume`. Recordings\n * already requested still complete. The next node after `resume` links to\n * the last one before `pause`, so the skipped steps drop out of the graph.\n */\n pause(): void {\n this.paused = true;\n }\n\n /** Undoes `pause`. The current page is not recorded until its URL changes. */\n resume(): void {\n this.paused = false;\n }\n\n /** Waits until every page recording requested so far has finished. */\n async waitForCapture(): 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 onPage = this.contexts.get(context);\n if (onPage) {\n context.off('page', 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 test's raw graph for the reporter, 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 (pageId) => this.pageState(pageId)?.pauseAnchor,\n );\n\n const failures = nodes.filter(\n (item) => item.status === 'uncaptured' && item.reason === 'error',\n );\n await this.writeRawGraph(this.buildRawGraph(nodes, failures.length));\n\n const mismatched = nodes.filter((item) => item.tagMismatches?.length);\n if (\n this.options.strictNodeTags &&\n mismatched.length &&\n this.testInfo.status === 'passed'\n ) {\n throw new Error(\n 'Ralph: the app reported tags outside what the test declared, on ' +\n mismatched\n .map(\n (item) => item.url + ' (' + item.tagMismatches!.join(', ') + ')',\n )\n .join('; ') +\n '.',\n );\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): 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 browserName: page.context().browser()?.browserType().name(),\n },\n lastUrl: '',\n generation: 0,\n sequence: 0,\n declared: new Map(),\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 const openerState = this.pages.get(opener);\n state.info.openerPageId = openerState?.info.pageId;\n state.pauseAnchor ??= openerState?.pauseAnchor;\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 if (this.paused && state.sequence === 0) {\n state.pauseAnchor ??= this.lastRequestedNodeId;\n }\n\n // A same-URL navigation is not leaving the page.\n const url = state.page.url();\n if (url === state.lastUrl) {\n return;\n }\n\n state.generation++;\n state.lastUrl = url;\n state.declared.clear();\n\n if (!this.paused && isHttpUrl(url)) {\n // Automatic recordings never fail the test; failures land in the manifest.\n void this.enqueueCreateNodeRequest(state, 'url-change').catch(\n () => undefined,\n );\n }\n }\n\n private async recordConditionChange(state: PageState): Promise<void> {\n if (!this.paused && isHttpUrl(state.page.url())) {\n await this.enqueueCreateNodeRequest(state, 'condition-change');\n }\n }\n\n private declaredConditions(state: PageState): NodeConditions {\n return mergeConditions([...state.declared.values()]);\n }\n\n private async reportedTags(page: Page): Promise<RecordedTags | undefined> {\n return page\n .evaluate(() =>\n (window as { __ralphTags?: () => RecordedTags }).__ralphTags?.(),\n )\n .catch(() => undefined);\n }\n\n // ---------------------------------------------------------------------------\n // Recording\n // ---------------------------------------------------------------------------\n\n /** Queues a recording behind the tab's earlier ones. */\n private enqueueCreateNodeRequest(\n state: PageState,\n trigger: RawNode['trigger'],\n ): Promise<void> {\n const url = state.page.url();\n if (!isHttpUrl(url)) {\n throw new Error('Ralph recordings require an HTTP or HTTPS page.');\n }\n\n const request: CreateNodeRequest = {\n nodeId: randomUUID(),\n pageId: state.info.pageId,\n sequence: state.sequence++,\n requestedAt: new Date().toISOString(),\n actualUrl: url,\n url,\n trigger,\n };\n this.requestedAtMs.set(request.nodeId, Date.now());\n this.lastRequestedNodeId = request.nodeId;\n\n const generation = state.generation;\n const task = state.tail.then(() =>\n this.createNode(state, generation, request),\n );\n state.tail = task.catch(() => undefined);\n\n return task;\n }\n\n /**\n * Creates the requested node: `recorded`, or `uncaptured` if the tab\n * navigates or closes first, or the record timeout passes.\n */\n private async createNode(\n state: PageState,\n generation: number,\n request: CreateNodeRequest,\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 !== 'explicit') {\n await this.waitForPageToSettle(page, deadline, check);\n }\n\n check();\n\n const conditions = this.declaredConditions(state);\n const reported = await wait(this.reportedTags(page));\n const mismatches = mismatchesOf(conditions, reported);\n const node = nodeKey(conditions);\n\n // The upload merges captures of one URL and conditions into one node,\n // so a second automatic capture of the node just recorded would be\n // discarded. An explicit one is the test retaking that node's\n // screenshot, so it replaces the capture instead of adding one.\n const sameNode =\n state.lastRecorded?.url === request.url &&\n state.lastRecorded.node === node;\n if (sameNode && request.trigger !== 'explicit') {\n return;\n }\n\n if (\n reported === undefined &&\n compactConditions(conditions) !== undefined &&\n !warnedNoTracker\n ) {\n warnedNoTracker = true;\n console.warn(\n 'Ralph: this page runs no tracker, so the node conditions the test declares cannot be checked against the tags the app reports. Production events must carry the same tags, or these nodes will match no visitors.',\n );\n }\n\n if (this.options.reduceMotion) {\n check();\n // Half the remaining budget, so the screenshot still has time.\n await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));\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 const recorded: RecordedNode = {\n ...request,\n ...withConditions(conditions),\n ...(reported === undefined ? {} : { reportedTags: reported }),\n ...(mismatches.length > 0 ? { tagMismatches: mismatches } : {}),\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\n const retaken = sameNode\n ? this.nodes.findIndex(\n ({ nodeId }) => nodeId === state.lastRecorded?.nodeId,\n )\n : -1;\n\n if (retaken === -1) {\n this.nodes.push(recorded);\n state.lastRecorded = { url: request.url, node, nodeId: request.nodeId };\n } else {\n // Keeps the capture's id and place, which later captures link from.\n const { nodeId, sequence, requestedAt } = this.nodes[retaken]!;\n this.nodes[retaken] = { ...recorded, nodeId, sequence, requestedAt };\n }\n } catch (error) {\n this.nodes.push({\n ...request,\n ...withConditions(this.declaredConditions(state)),\n status: 'uncaptured',\n reason: navigatedAway() ? 'navigated-away' : 'error',\n message: error instanceof Error ? error.message : String(error),\n });\n // What the test asked for directly fails it; what the page did on its\n // own lands in the manifest.\n if (request.trigger !== 'url-change') {\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 = SCREENSHOT_ATTACHMENT_PREFIX + 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 // Playwright can delete test output immediately after testEnd, before\n // the reporter merges it. Body attachments survive that cleanup.\n ...(this.testInfo.config.preserveOutput === 'always'\n ? { path: this.testInfo.outputPath(path) }\n : { body: image.bytes }),\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 pageState(pageId: string): PageState | undefined {\n return [...this.pages.values()].find(\n (state) => state.info.pageId === pageId,\n );\n }\n\n private pageInfo(pageId: string): PageInfo | undefined {\n return this.pageState(pageId)?.info;\n }\n\n private buildRawGraph(nodes: RawNode[], failureCount: number): RawGraphFile {\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 config: this.config,\n mode: this.mode,\n rawGraph: {\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 // Failed nodes fail the test in `finish`, after this is written. The\n // reporter replaces it with the final status anyway.\n status:\n failureCount && testPassed\n ? 'failed'\n : (testInfo.status ?? 'unknown'),\n expectedStatus: testInfo.expectedStatus,\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\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\n private async writeRawGraph(file: RawGraphFile): Promise<void> {\n const path = this.testInfo.outputPath(OUTPUT_DIR, 'raw_graph.json');\n const body = JSON.stringify(file, null, 2) + '\\n';\n await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });\n await writeFile(path, body);\n await this.testInfo.attach(MANIFEST_ATTACHMENT, {\n ...(this.testInfo.config.preserveOutput === 'always'\n ? { path }\n : { body }),\n contentType: 'application/json',\n });\n }\n}\n\n// -----------------------------------------------------------------------------\n// Helpers\n// -----------------------------------------------------------------------------\n\nfunction withConditions(conditions: NodeConditions) {\n const compact = compactConditions(conditions);\n return compact === undefined ? {} : { conditions: compact };\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. Failing both, a tab\n * first used while paused links to the last node before the pause.\n * Uncaptured nodes stay in the chain, as regular nodes in the server's graph.\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 pauseAnchorOf: (pageId: string) => string | undefined,\n): void {\n const byPage = new Map<string, RawNode[]>();\n const byId = new Map<string, RawNode>();\n for (const item of nodes) {\n byPage.set(item.pageId, [...(byPage.get(item.pageId) ?? []), item]);\n byId.set(item.nodeId, 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 for (const items of byPage.values()) {\n items.forEach((item, index) => {\n const from =\n index > 0\n ? items[index - 1]\n : (openerNodeBefore(item) ??\n byId.get(pauseAnchorOf(item.pageId) ?? ''));\n if (from) {\n item.previousNodeId = from.nodeId;\n }\n });\n }\n}\n","import { Readable } from 'node:stream';\nimport { pipeline } from 'node:stream/promises';\nimport { createGzip } from 'node:zlib';\n\nimport { pack } from 'tar-stream';\n\nimport { imageExtension, type Screenshot } from './image';\nimport type { RawGraphManifest } 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};\n/** An artifact the server reserved, which the upload and the manifest must name. */\nexport type PreparedUpload = {\n artifactId: string;\n /** Where the result opens in the Ralph dashboard, when the server knows. */\n resultUrl?: string;\n};\n/** The bundle is stored and queued; the result page shows it being processed. */\nexport type UploadReceipt = {\n result: 'ok';\n status: 'queued';\n artifactId: string;\n resultUrl?: string;\n replayed: boolean;\n};\n\nexport const DEFAULT_API_URL = 'https://api.ralphralph.ai';\nexport const DASHBOARD_URL = 'https://dash.ralphralph.ai';\n\n/** Where the uploaded run opens: the server's link, else the Ralph dashboard. */\nexport function resultLink({\n artifactId,\n resultUrl,\n}: {\n artifactId: string;\n resultUrl?: string;\n}): string {\n return (\n resultUrl ?? `${DASHBOARD_URL}/uploads/${encodeURIComponent(artifactId)}`\n );\n}\n\n/** Where the reporter uploads: its options, else the environment, else Ralph's production server. */\nexport function resolveUploadOptions(options: {\n apiUrl?: string;\n appId?: string;\n uploadTimeoutMs?: number;\n}): UploadOptions {\n const uploadKey = process.env['RALPH_UPLOAD_KEY'];\n const apiUrl =\n options.apiUrl || process.env['RALPH_API_URL'] || DEFAULT_API_URL;\n const appId = options.appId ?? process.env['RALPH_APP_ID'];\n if (!uploadKey) {\n throw new Error(\n `To upload to Ralph, set RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`,\n );\n }\n if (!appId) {\n throw new Error(\n 'To upload to Ralph, set RALPH_APP_ID (or the reporter option appId) to the app the recordings belong to.',\n );\n }\n return { uploadKey, apiUrl, appId, timeoutMs: options.uploadTimeoutMs };\n}\n\n// Mirrors the server's limits, so an oversized run fails before it is sent.\nexport const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;\nexport const MAX_SCREENSHOTS = 5000;\nexport const MAX_SCREENSHOT_BYTES = 256 * 1024 * 1024;\nexport const MAX_COMPRESSED_BYTES = 256 * 1024 * 1024;\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/** Where a node's screenshot sits inside the bundle. */\nexport function bundleImagePath(\n nodeId: string,\n contentType: Screenshot['contentType'],\n): string {\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 `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`;\n}\n\nconst TOO_BIG =\n 'This run is too big to upload to Ralph. Record fewer pages or screen sizes, or split the tests across runs.';\n\nclass TooBigError extends Error {\n constructor() {\n super(TOO_BIG);\n }\n}\n\n/**\n * Packs the manifest and screenshots into a gzipped tar archive, produced as it\n * is read so the compressed bundle is never held whole:\n *\n * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }\n * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)\n *\n * Fails with a `TooBigError` once the compressed bytes pass the limit.\n */\nfunction rawGraphBundleStream(\n manifest: RawGraphManifest,\n screenshots: readonly RawGraphScreenshot[],\n): Readable {\n if (\n Buffer.byteLength(JSON.stringify(manifest)) > MAX_MANIFEST_BYTES ||\n screenshots.length > MAX_SCREENSHOTS ||\n screenshots.reduce(\n (total, screenshot) => total + screenshot.bytes.byteLength,\n 0,\n ) > MAX_SCREENSHOT_BYTES\n ) {\n throw new TooBigError();\n }\n\n const images = screenshots.map(({ nodeId, contentType, bytes }) => ({\n nodeId,\n path: bundleImagePath(nodeId, contentType),\n bytes,\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 artifact byte-identical.\n const attrs = { mtime: new Date(0), uid: 0, gid: 0 };\n const tar = pack();\n\n // Cancellation destroys queued entries too; handle their errors through the\n // pack's pipeline instead of leaving unhandled stream error events.\n const onEntryError = (error: Error) => tar.destroy(error);\n tar\n .entry({ name: RAW_GRAPH_BUNDLE_INDEX, ...attrs }, JSON.stringify(index))\n .on('error', onEntryError);\n\n for (const { path, bytes } of images) {\n tar\n .entry({ name: path, ...attrs }, Buffer.from(bytes))\n .on('error', onEntryError);\n }\n tar.finalize();\n\n const gzip = createGzip();\n pipeline(tar, gzip).catch((error: unknown) => gzip.destroy(error as Error));\n return Readable.from(\n (async function* () {\n let size = 0;\n for await (const chunk of gzip) {\n size += (chunk as Buffer).byteLength;\n if (size > MAX_COMPRESSED_BYTES) {\n throw new TooBigError();\n }\n yield chunk as Buffer;\n }\n })(),\n );\n}\n\n/** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */\nexport async function createRawGraphBundle(\n manifest: RawGraphManifest,\n screenshots: readonly RawGraphScreenshot[],\n): Promise<Uint8Array> {\n const chunks: Buffer[] = [];\n for await (const chunk of rawGraphBundleStream(manifest, screenshots)) {\n chunks.push(chunk as Buffer);\n }\n return Buffer.concat(chunks);\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\nfunction checkedApiUrl(options: UploadOptions, path: string): URL {\n if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) {\n throw new Error(\n `\"${options.appId}\" isn't a valid Ralph app id. Check RALPH_APP_ID.`,\n );\n }\n const url = new URL(path, options.apiUrl);\n if (\n !(\n url.protocol === 'https:' ||\n (url.protocol === 'http:' && isLoopback(url))\n )\n ) {\n throw new Error(\n `RALPH_API_URL must start with https:// (http:// only works for localhost), not ${options.apiUrl}.`,\n );\n }\n if (!options.uploadKey) {\n throw new Error(\n `To upload to Ralph, set RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`,\n );\n }\n return url;\n}\n\n/** Sends one request, turning every way it can fail into a message that says what to fix. */\nasync function request(\n url: URL,\n init: RequestInit & { duplex?: 'half' },\n options: UploadOptions,\n timeoutMs: number,\n): Promise<Record<string, unknown>> {\n let response: Response;\n try {\n response = await fetch(url, {\n ...init,\n method: 'POST',\n headers: {\n ...init.headers,\n Authorization: `Bearer ${options.uploadKey}`,\n },\n redirect: 'error',\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (error) {\n if (\n error instanceof TooBigError ||\n (error instanceof Error && error.cause instanceof TooBigError)\n ) {\n throw new Error(TOO_BIG, { cause: error });\n }\n throw new Error(describeRequestFailure(error, url, timeoutMs), {\n cause: error,\n });\n }\n\n if (!response.ok) {\n throw new Error(\n describeHttpFailure(\n response.status,\n await responseDetail(response),\n url,\n options.appId,\n ),\n );\n }\n\n try {\n return (await response.json()) as Record<string, unknown>;\n } catch {\n return {};\n }\n}\n\nconst unreadableReply = (url: URL) =>\n new Error(\n \"Upload to Ralph failed. Ralph's reply couldn't be read. Update @ralphralphai/playwright to the latest version and run again.\" +\n customServerHint(url),\n );\n\nconst PREPARE_TIMEOUT_MS = 30_000;\n\n/**\n * Reserves an artifact for the run. Its `resultUrl` works from now on, so the\n * run can be followed while it uploads and while Ralph processes it.\n */\nexport async function prepareArtifactUpload(\n options: UploadOptions,\n): Promise<PreparedUpload> {\n const url = checkedApiUrl(options, '/api/upload/artifact/prepare');\n const reply = await request(\n url,\n {\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ externalAppId: options.appId }),\n },\n options,\n PREPARE_TIMEOUT_MS,\n );\n if (\n reply['result'] !== 'ok' ||\n typeof reply['artifactId'] !== 'string' ||\n (reply['resultUrl'] !== undefined && typeof reply['resultUrl'] !== 'string')\n ) {\n throw unreadableReply(url);\n }\n return {\n artifactId: reply['artifactId'],\n resultUrl: reply['resultUrl'] as string | undefined,\n };\n}\n\n/**\n * Streams the run's bundle to the artifact `manifest.artifactId` names, which\n * `prepareArtifactUpload` has to have reserved. Ralph processes it after this\n * returns; the result page shows when it is ready.\n */\nexport async function uploadRawGraph(\n manifest: RawGraphManifest,\n screenshots: readonly RawGraphScreenshot[],\n options: UploadOptions,\n): Promise<UploadReceipt> {\n const url = checkedApiUrl(\n options,\n `/api/upload/web/apps/${options.appId}/artifact/${encodeURIComponent(manifest.artifactId)}`,\n );\n // Environment only, and absent from the public options, because only Ralph\n // developers run a server that accepts local storage.\n const storage = process.env['RALPH_UPLOAD_STORAGE'] || 'gcs';\n if (storage !== 'gcs' && storage !== 'local') {\n throw new Error(\n `RALPH_UPLOAD_STORAGE must be gcs or local, not ${storage}.`,\n );\n }\n if (storage === 'local') {\n url.searchParams.set('storage', 'local');\n }\n\n const timeoutMs = options.timeoutMs ?? 120_000;\n if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {\n throw new Error(\n 'uploadTimeoutMs in the Ralph reporter options must be a positive number of milliseconds.',\n );\n }\n\n const receipt = await request(\n url,\n {\n headers: { 'content-type': 'application/gzip' },\n body: Readable.toWeb(\n rawGraphBundleStream(manifest, screenshots),\n ) as ReadableStream,\n // Required by fetch for a streamed body.\n duplex: 'half',\n },\n options,\n timeoutMs,\n );\n if (\n receipt['result'] !== 'ok' ||\n receipt['status'] !== 'queued' ||\n receipt['artifactId'] !== manifest.artifactId ||\n (receipt['resultUrl'] !== undefined &&\n typeof receipt['resultUrl'] !== 'string') ||\n typeof receipt['replayed'] !== 'boolean'\n ) {\n throw unreadableReply(url);\n }\n return receipt as UploadReceipt;\n}\n\nconst CREATE_KEY_HINT =\n 'You can create an upload key in your project dashboard on Ralph.';\nconst SUPPORT_HINT = 'If it keeps failing, contact Ralph support.';\n\n/** Only users who set their own server need to hear about it. */\nfunction customServerHint(url: URL): string {\n return url.origin === new URL(DEFAULT_API_URL).origin\n ? ''\n : ` Also check RALPH_API_URL (${url.origin}) is right.`;\n}\n\nfunction describeRequestFailure(\n error: unknown,\n url: URL,\n timeoutMs: number,\n): string {\n if (error instanceof Error && error.name === 'TimeoutError') {\n return (\n `Upload to Ralph took longer than ${timeoutMs / 1000}s and was stopped. ` +\n 'Check your network, or raise uploadTimeoutMs in the reporter options.'\n );\n }\n const cause =\n error instanceof Error && error.cause instanceof Error\n ? error.cause.message\n : error instanceof Error\n ? error.message\n : String(error);\n return (\n `Couldn't connect to Ralph (${cause}). ` +\n `Check that this machine can reach ${url.origin}.` +\n customServerHint(url)\n );\n}\n\nfunction describeHttpFailure(\n status: number,\n detail: string | undefined,\n url: URL,\n appId: string,\n): string {\n let message: string;\n if (status === 401) {\n message = `Ralph didn't accept your upload key. Check RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`;\n } else if (status === 403) {\n message = `Your upload key can't upload to the app \"${appId}\". Check RALPH_APP_ID is an app in the same project as the key.`;\n } else if (status === 404) {\n message =\n `Ralph couldn't find the app \"${appId}\". Check RALPH_APP_ID.` +\n customServerHint(url);\n } else if (status === 413) {\n message =\n 'This run is too big to upload. Record fewer pages or screen sizes, or split the tests across runs.';\n } else if (status === 429) {\n message = 'Too many uploads right now. Wait a minute and run again.';\n } else if (status >= 500) {\n message = `Something went wrong on Ralph's side. Run again later. ${SUPPORT_HINT}`;\n } else {\n message = `Ralph couldn't accept this upload. Update @ralphralphai/playwright to the latest version and run again. ${SUPPORT_HINT}`;\n }\n return `Upload to Ralph failed. ${message} (HTTP ${status}${detail ? `: ${detail}` : ''})`;\n}\n\n/** The server's reason for rejecting the upload, kept short. */\nasync function responseDetail(response: Response): Promise<string | undefined> {\n let text: string;\n try {\n text = (await response.text()).trim();\n } catch {\n return undefined;\n }\n try {\n const body = JSON.parse(text) as {\n result?: unknown;\n message?: unknown;\n error?: unknown;\n };\n const message = body.result ?? body.message ?? body.error;\n if (typeof message === 'string') {\n text = message;\n }\n } catch {\n // Not JSON; use the text as is.\n }\n text = text.replace(/\\s+/g, ' ');\n return text.length > 200 ? text.slice(0, 200) + '…' : text || undefined;\n}\n"],"mappings":"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"}