@pixeyedev/playwright 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1 @@
1
- /**
2
- * Transform Argos CI code to Pixeye.
3
- * Usage: import and call transformArgos(sourceCode) -> transformed code
4
- *
5
- * Handles:
6
- * - import { argosScreenshot } from '@argos-ci/playwright' -> import { pixeye } from '@pixeyedev/playwright'
7
- * - argosScreenshot(page, name, opts) -> pixeye.screenshot(page, name, opts)
8
- * - { argosCSS: '...' } -> { css: '...' }
9
- */
10
1
  export declare const transformArgos: (source: string) => string;
@@ -1,19 +1,7 @@
1
- /**
2
- * Transform Argos CI code to Pixeye.
3
- * Usage: import and call transformArgos(sourceCode) -> transformed code
4
- *
5
- * Handles:
6
- * - import { argosScreenshot } from '@argos-ci/playwright' -> import { pixeye } from '@pixeyedev/playwright'
7
- * - argosScreenshot(page, name, opts) -> pixeye.screenshot(page, name, opts)
8
- * - { argosCSS: '...' } -> { css: '...' }
9
- */
10
1
  export const transformArgos = (source) => {
11
2
  let result = source;
12
- // 1. Transform import
13
3
  result = result.replace(/import\s*\{\s*argosScreenshot\s*\}\s*from\s*['"]@argos-ci\/playwright['"]/g, "import { pixeye } from '@pixeyedev/playwright'");
14
- // 2. Transform function calls
15
4
  result = result.replace(/argosScreenshot\s*\(/g, 'pixeye.screenshot(');
16
- // 3. Transform option names
17
5
  result = result.replace(/argosCSS\s*:/g, 'css:');
18
6
  return result;
19
7
  };
@@ -1,10 +1 @@
1
- /**
2
- * Transform Percy code to Pixeye.
3
- * Usage: import and call transformPercy(sourceCode) -> transformed code
4
- *
5
- * Handles:
6
- * - import percySnapshot from '@percy/playwright' -> import { pixeye } from '@pixeyedev/playwright'
7
- * - percySnapshot(page, name, opts) -> pixeye.screenshot(page, name, opts)
8
- * - { percyCSS: '...' } -> { css: '...' }
9
- */
10
1
  export declare const transformPercy: (source: string) => string;
@@ -1,19 +1,7 @@
1
- /**
2
- * Transform Percy code to Pixeye.
3
- * Usage: import and call transformPercy(sourceCode) -> transformed code
4
- *
5
- * Handles:
6
- * - import percySnapshot from '@percy/playwright' -> import { pixeye } from '@pixeyedev/playwright'
7
- * - percySnapshot(page, name, opts) -> pixeye.screenshot(page, name, opts)
8
- * - { percyCSS: '...' } -> { css: '...' }
9
- */
10
1
  export const transformPercy = (source) => {
11
2
  let result = source;
12
- // 1. Transform import (default import)
13
3
  result = result.replace(/import\s+percySnapshot\s+from\s*['"]@percy\/playwright['"]/g, "import { pixeye } from '@pixeyedev/playwright'");
14
- // 2. Transform function calls
15
4
  result = result.replace(/percySnapshot\s*\(/g, 'pixeye.screenshot(');
16
- // 3. Transform option names
17
5
  result = result.replace(/percyCSS\s*:/g, 'css:');
18
6
  return result;
19
7
  };
package/dist/fixture.d.ts CHANGED
@@ -1,17 +1,3 @@
1
- /**
2
- * Playwright test fixture that provides `pixeye` in every test.
3
- *
4
- * Usage:
5
- * import { test, expect } from '@pixeyedev/playwright';
6
- *
7
- * test('login page', async ({ page, pixeye }) => {
8
- * await page.goto('/login');
9
- * await pixeye.screenshot(page, 'login-page');
10
- * });
11
- *
12
- * The fixture automatically populates testFile and testName from
13
- * Playwright's testInfo, so metadata is always accurate.
14
- */
15
1
  import { expect } from '@playwright/test';
16
2
  import { screenshot, type ScreenshotOptions } from './screenshot.js';
17
3
  export interface PixeyeFixture {
package/dist/fixture.js CHANGED
@@ -1,22 +1,7 @@
1
- /**
2
- * Playwright test fixture that provides `pixeye` in every test.
3
- *
4
- * Usage:
5
- * import { test, expect } from '@pixeyedev/playwright';
6
- *
7
- * test('login page', async ({ page, pixeye }) => {
8
- * await page.goto('/login');
9
- * await pixeye.screenshot(page, 'login-page');
10
- * });
11
- *
12
- * The fixture automatically populates testFile and testName from
13
- * Playwright's testInfo, so metadata is always accurate.
14
- */
15
1
  import { join } from 'node:path';
16
2
  import { expect, test as base } from '@playwright/test';
17
3
  import { screenshot, setContext } from './screenshot.js';
18
4
  export const test = base.extend({
19
- // eslint-disable-next-line no-empty-pattern
20
5
  pixeye: async ({}, use, testInfo) => {
21
6
  const outputDir = join(process.cwd(), '.pixeye', 'screenshots');
22
7
  const testFile = testInfo.file ? testInfo.file.replace(process.cwd() + '/', '') : 'unknown';
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  export { expect, test } from './fixture.js';
2
2
  export { getContext, PIXEYE_BROWSER_ARGS, screenshot, setContext } from './screenshot.js';
3
- // ── Convenience namespace (backwards compat with design docs) ───────────────
4
3
  import { screenshot as screenshotFn } from './screenshot.js';
5
4
  export const pixeye = { screenshot: screenshotFn };
@@ -3,25 +3,13 @@ interface ReporterOptions {
3
3
  token?: string;
4
4
  server?: string;
5
5
  uploadConcurrency?: number;
6
- /**
7
- * Fail the test run (non-zero exit) when the upload is rejected for a
8
- * CUSTOMER misconfiguration (bad token, lapsed plan/quota, permissions) so a
9
- * run can't pass green with zero screenshots uploaded. Default `true`. Our
10
- * transient faults (5xx/network) never fail the run regardless. Opt out with
11
- * `failOnError: false` or `PIXEYE_FAIL_ON_ERROR=false`.
12
- */
13
6
  failOnError?: boolean;
14
7
  }
15
8
  declare const detectShard: (config: FullConfig) => {
16
9
  index: number;
17
10
  total: number;
18
11
  };
19
- /** The tests that failed after their retries, by the id the sidecar carries
20
- * (`testInfo.testId` = `TestCase.id`, unique across files, describes and
21
- * projects); a flaky test's last files are from the attempt that passed. */
22
12
  export declare const failedTestIds: (suite: Suite | null) => Set<string>;
23
- /** The server reads `testStatus` and keeps such a shot out of the review
24
- * and out of the baselines (`failed` diff status). */
25
13
  export declare const markFailedTests: (metadata: Record<string, unknown> | undefined, failed: Set<string>) => Record<string, unknown> | undefined;
26
14
  declare class PixeyeReporter implements Reporter {
27
15
  private options;
package/dist/reporter.js CHANGED
@@ -1,15 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { detectCIEnvironment, discoverScreenshots, isUploadMisconfig, PixeyeClient, renderUploadFailureBanner, resolveFailOnError, runParallel, swapImageExtension, uploadErrorSummary } from '@pixeyedev/core';
3
+ import { detectCIEnvironment, discoverScreenshots, PixeyeClient, renderUploadFailureBanner, resolveFailOnError, resolveUploadConcurrency, runParallel, swapImageExtension } from '@pixeyedev/core';
4
4
  import { CONTENT_TYPE_BY_FORMAT } from '@pixeyedev/shared';
5
5
  import { getContext } from './screenshot.js';
6
- const DEFAULT_UPLOAD_CONCURRENCY = 32;
7
6
  const detectShard = (config) => {
8
- // 1. Playwright config (1-indexed → 0-indexed)
9
7
  if (config.shard) {
10
8
  return { index: config.shard.current - 1, total: config.shard.total };
11
9
  }
12
- // 2. Environment variables (0-indexed)
13
10
  const envIndex = process.env.PIXEYE_SHARD_INDEX;
14
11
  const envTotal = process.env.PIXEYE_SHARD_TOTAL;
15
12
  if (envIndex !== undefined) {
@@ -18,20 +15,14 @@ const detectShard = (config) => {
18
15
  total: envTotal ? parseInt(envTotal, 10) : 1
19
16
  };
20
17
  }
21
- // 3. Default: no sharding
22
18
  return { index: 0, total: 1 };
23
19
  };
24
20
  const detectRunner = () => {
25
21
  return 'playwright';
26
22
  };
27
- /** The tests that failed after their retries, by the id the sidecar carries
28
- * (`testInfo.testId` = `TestCase.id`, unique across files, describes and
29
- * projects); a flaky test's last files are from the attempt that passed. */
30
23
  export const failedTestIds = (suite) => new Set((suite ? suite.allTests() : [])
31
24
  .filter(test => test.outcome() === 'unexpected')
32
25
  .map(test => test.id));
33
- /** The server reads `testStatus` and keeps such a shot out of the review
34
- * and out of the baselines (`failed` diff status). */
35
26
  export const markFailedTests = (metadata, failed) => metadata && typeof metadata.testId === 'string' && failed.has(metadata.testId)
36
27
  ? { ...metadata, testStatus: 'failed' }
37
28
  : metadata;
@@ -56,8 +47,6 @@ class PixeyeReporter {
56
47
  this.shard = detectShard(config);
57
48
  }
58
49
  onTestEnd(_test, _result) {
59
- // Screenshots are already saved to disk by pixeye.screenshot().
60
- // Nothing to do per-test.
61
50
  }
62
51
  async onEnd(_result) {
63
52
  if (this.skipUpload) {
@@ -68,7 +57,6 @@ class PixeyeReporter {
68
57
  if (!token) {
69
58
  return;
70
59
  }
71
- // Discover screenshots saved by pixeye.screenshot()
72
60
  const ctx = getContext();
73
61
  const outputDir = ctx?.outputDir ?? join(process.cwd(), '.pixeye', 'screenshots');
74
62
  let files;
@@ -76,8 +64,6 @@ class PixeyeReporter {
76
64
  files = await discoverScreenshots(outputDir);
77
65
  }
78
66
  catch (err) {
79
- // A missing directory yields an empty list, so a THROW here is always a
80
- // file we could not read — say which, instead of "no screenshots".
81
67
  console.error(`[pixeye] Could not read screenshots in ${outputDir}.`);
82
68
  console.error(`[pixeye] ${err.message}`);
83
69
  return;
@@ -95,11 +81,10 @@ class PixeyeReporter {
95
81
  return;
96
82
  }
97
83
  const runner = detectRunner();
98
- const concurrency = this.options.uploadConcurrency ?? DEFAULT_UPLOAD_CONCURRENCY;
84
+ let observes = false;
99
85
  try {
86
+ const concurrency = resolveUploadConcurrency(this.options.uploadConcurrency);
100
87
  const client = new PixeyeClient({ serverUrl: server, token });
101
- // 1. Create build. A run with a failed test is partial: the shots it
102
- // never reached are not removed, and keep their baseline.
103
88
  const failed = failedTestIds(this.suite);
104
89
  const prNumber = process.env.PIXEYE_PR ? parseInt(process.env.PIXEYE_PR, 10) : ci?.prNumber;
105
90
  const buildResponse = await client.createBuild({
@@ -108,15 +93,12 @@ class PixeyeReporter {
108
93
  branch,
109
94
  prNumber: prNumber && !isNaN(prNumber) ? prNumber : undefined,
110
95
  shardCount: this.shard.total,
111
- partial: failed.size > 0,
112
96
  runner,
113
97
  baseBranch: ci?.baseBranch
114
98
  });
115
99
  const buildId = buildResponse.build.id;
116
- // 2. Presign upload URLs
100
+ observes = client.observes;
117
101
  const hashes = files.map(f => f.fileHash);
118
- // Only non-PNG needs declaring: the server defaults to png. Filtering
119
- // on 'webp' alone stored a sniffed jpeg under a .png key.
120
102
  const formats = Object.fromEntries(files.filter(f => f.format !== 'png').map(f => [f.fileHash, f.format]));
121
103
  const presign = await client.presignUpload({
122
104
  buildId,
@@ -128,7 +110,6 @@ class PixeyeReporter {
128
110
  console.warn(`[pixeye] ${w}`);
129
111
  }
130
112
  }
131
- // 3. Upload new files
132
113
  const hashUrlPairs = Object.entries(presign.urls);
133
114
  const hashToFile = new Map(files.map(f => [f.fileHash, f]));
134
115
  let uploaded = 0;
@@ -144,9 +125,7 @@ class PixeyeReporter {
144
125
  uploaded++;
145
126
  });
146
127
  }
147
- // 4. Read metadata sidecars and build manifest
148
128
  const manifest = await Promise.all(files.map(async (f) => {
149
- // Try to read companion .meta.json
150
129
  const metaPath = swapImageExtension(f.filePath, '.meta.json');
151
130
  let metadata;
152
131
  try {
@@ -154,7 +133,6 @@ class PixeyeReporter {
154
133
  metadata = JSON.parse(metaContent);
155
134
  }
156
135
  catch {
157
- // No sidecar — that's fine
158
136
  }
159
137
  return {
160
138
  name: f.name,
@@ -165,7 +143,6 @@ class PixeyeReporter {
165
143
  metadata: markFailedTests(metadata, failed)
166
144
  };
167
145
  }));
168
- // 5. Submit shard
169
146
  await client.submitShard({
170
147
  buildId,
171
148
  shardIndex: this.shard.index,
@@ -174,23 +151,10 @@ class PixeyeReporter {
174
151
  console.log(`[pixeye] ${files.length} screenshots (${uploaded} new, ${presign.existing.length} cached) — ${server}/builds/${buildId}`);
175
152
  }
176
153
  catch (err) {
177
- // P0-2: a CUSTOMER misconfiguration (bad token, lapsed plan/quota,
178
- // permissions) must NOT pass silently — surface a loud banner and fail
179
- // the run by default so a green CI can't hide zero uploaded screenshots.
180
- // Our transient faults (5xx/network/R2) only warn — never break their CI.
181
- if (isUploadMisconfig(err)) {
182
- console.error(renderUploadFailureBanner(err, { willFail: this.failOnError }));
183
- if (this.failOnError) {
184
- // Playwright IGNORES `process.exitCode` — its runner calls
185
- // `process.exit(status === 'passed' ? 0 : 1)`, overriding it. The ONLY
186
- // reporter-side lever is returning a status from onEnd, which the
187
- // multiplexer applies (`result.status = outResult.status`). Return
188
- // 'failed' so a passing run with a rejected upload still exits non-zero.
189
- return { status: 'failed' };
190
- }
191
- }
192
- else {
193
- console.warn(`[pixeye] Upload failed (transient — CI not failed): ${uploadErrorSummary(err)}`);
154
+ const willFail = this.failOnError && !observes;
155
+ console.error(renderUploadFailureBanner(err, { willFail }));
156
+ if (willFail) {
157
+ return { status: 'failed' };
194
158
  }
195
159
  }
196
160
  }
@@ -1,5 +1,4 @@
1
1
  import type { Locator, Page } from '@playwright/test';
2
- /** Browser args recommended for deterministic screenshots. */
3
2
  export declare const PIXEYE_BROWSER_ARGS: string[];
4
3
  export interface ScreenshotOptions {
5
4
  fullPage?: boolean;
@@ -2,21 +2,16 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- /** Browser args recommended for deterministic screenshots. */
6
5
  export const PIXEYE_BROWSER_ARGS = [
7
6
  '--force-device-scale-factor=1',
8
7
  '--font-render-hinting=none',
9
8
  '--disable-lcd-text'
10
9
  ];
11
- // Shared context set by the reporter or test setup
12
10
  let _context = null;
13
11
  export const setContext = (ctx) => {
14
12
  _context = ctx;
15
13
  };
16
14
  export const getContext = () => _context;
17
- // ── IIFE loader ──────────────────────────────────────────────────────────────
18
- // The browser IIFE is copied into dist/ at build time (see package.json build script).
19
- // This avoids a runtime dependency on @pixeyedev/browser, which is private/internal.
20
15
  const THIS_DIR = dirname(fileURLToPath(import.meta.url));
21
16
  let _iifeCache = null;
22
17
  const loadIIFE = async () => {
@@ -26,13 +21,9 @@ const loadIIFE = async () => {
26
21
  _iifeCache = await readFile(join(THIS_DIR, 'browser.iife.js'), 'utf-8');
27
22
  return _iifeCache;
28
23
  };
29
- // ── Helpers ──────────────────────────────────────────────────────────────────
30
24
  const injectBrowserScripts = async (page) => {
31
25
  const iife = await loadIIFE();
32
- // Check if already injected to avoid double-injection
33
- const alreadyInjected = await page.evaluate(
34
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
- () => typeof window.__pixeye !== 'undefined');
26
+ const alreadyInjected = await page.evaluate(() => typeof window.__pixeye !== 'undefined');
36
27
  if (!alreadyInjected) {
37
28
  await page.evaluate(iife);
38
29
  }
@@ -49,7 +40,6 @@ const isLocator = (target) => {
49
40
  const sanitizePath = (str) => {
50
41
  return str.replace(/[^a-zA-Z0-9._/-]/g, '_');
51
42
  };
52
- // ── Mask resolution ──────────────────────────────────────────────────────────
53
43
  const resolveMaskRegions = async (page, masks) => {
54
44
  const regions = [];
55
45
  for (const selectorOrLocator of masks) {
@@ -72,7 +62,6 @@ const resolveMaskRegions = async (page, masks) => {
72
62
  }
73
63
  }
74
64
  else {
75
- // Locator
76
65
  const box = await selectorOrLocator.boundingBox();
77
66
  if (box) {
78
67
  regions.push({
@@ -90,7 +79,6 @@ const resolveMaskRegions = async (page, masks) => {
90
79
  }
91
80
  return regions;
92
81
  };
93
- // ── Capture with retry ───────────────────────────────────────────────────────
94
82
  const RETRY_DELAY_MS = 100;
95
83
  const captureOnce = async (target, options) => {
96
84
  const screenshotOpts = {};
@@ -115,7 +103,6 @@ const captureStable = async (target, options) => {
115
103
  if (retries <= 0) {
116
104
  return captureOnce(target, options);
117
105
  }
118
- // Capture retries+1 times, find first consecutive duplicate (stable frame)
119
106
  const captures = [];
120
107
  const page = getPage(target);
121
108
  for (let i = 0; i <= retries; i++) {
@@ -130,13 +117,10 @@ const captureStable = async (target, options) => {
130
117
  return captures[i];
131
118
  }
132
119
  }
133
- // No stable frame found — return the last capture
134
120
  return captures[captures.length - 1];
135
121
  };
136
- // ── Main screenshot function ─────────────────────────────────────────────────
137
122
  export const screenshot = async (target, name, options = {}) => {
138
123
  const page = getPage(target);
139
- // Determine output directory
140
124
  const outputDir = _context?.outputDir ?? join(process.cwd(), '.pixeye', 'screenshots');
141
125
  const testFile = _context?.testFile ?? 'unknown';
142
126
  const testName = _context?.testName ?? '';
@@ -145,38 +129,27 @@ export const screenshot = async (target, name, options = {}) => {
145
129
  await mkdir(dir, { recursive: true });
146
130
  const pngPath = join(dir, `${name}.png`);
147
131
  const metaPath = join(dir, `${name}.meta.json`);
148
- // 1. Inject browser scripts
149
132
  await injectBrowserScripts(page);
150
- // 2. Stabilize the page
151
133
  await page.evaluate((opts) => {
152
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
134
  window.__pixeye.stabilize({
154
135
  css: opts.css,
155
136
  colorScheme: opts.colorScheme
156
137
  });
157
138
  }, { css: options.css, colorScheme: options.colorScheme });
158
- // 3. Wait for page to be ready
159
139
  await page.evaluate((timeout) => {
160
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
140
  return window.__pixeye.waitForReady({ timeout });
162
141
  }, options.waitForTimeout ?? 10_000);
163
- // 4. Custom waitFor selector
164
142
  if (options.waitFor) {
165
143
  await page.waitForSelector(options.waitFor, {
166
144
  timeout: options.waitForTimeout ?? 10_000
167
145
  });
168
146
  }
169
- // 5. Scan DOM for region attributes
170
147
  const scannedRegions = await page.evaluate(() => {
171
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
148
  return window.__pixeye.scanRegions();
173
149
  });
174
- // 6. Resolve mask selectors/locators to region bounds
175
150
  const maskRegions = options.mask ? await resolveMaskRegions(page, options.mask) : [];
176
151
  const allRegions = [...scannedRegions, ...maskRegions];
177
- // 7. Capture screenshot (with optional retry for stability)
178
152
  const buffer = await captureStable(target, options);
179
- // 8. Get component bounds for element-level screenshots
180
153
  let componentBounds = null;
181
154
  if (isLocator(target)) {
182
155
  const box = await target.boundingBox();
@@ -189,10 +162,8 @@ export const screenshot = async (target, name, options = {}) => {
189
162
  };
190
163
  }
191
164
  }
192
- // 9. Gather viewport + browser info
193
165
  const viewportSize = page.viewportSize();
194
166
  const browserName = page.context().browser()?.browserType().name() ?? 'unknown';
195
- // 10. Build metadata sidecar
196
167
  const metadata = {
197
168
  version: 1,
198
169
  viewport: viewportSize ? { width: viewportSize.width, height: viewportSize.height } : undefined,
@@ -205,7 +176,6 @@ export const screenshot = async (target, name, options = {}) => {
205
176
  regions: allRegions.length > 0 ? allRegions : undefined,
206
177
  componentBounds
207
178
  };
208
- // 11. Write .png and .meta.json
209
179
  await writeFile(pngPath, buffer);
210
180
  await writeFile(metaPath, JSON.stringify(metadata, null, 2) + '\n');
211
181
  return pngPath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixeyedev/playwright",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -35,8 +35,8 @@
35
35
  "ai-check": "exit 0"
36
36
  },
37
37
  "dependencies": {
38
- "@pixeyedev/core": "^0.4.0",
39
- "@pixeyedev/shared": "^0.4.0"
38
+ "@pixeyedev/core": "^0.4.7",
39
+ "@pixeyedev/shared": "^0.4.7"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@playwright/test": ">=1.40.0"