@pixeyedev/playwright 0.4.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.
@@ -0,0 +1,20 @@
1
+ "use strict";var __pixeye=(()=>{var c=Object.defineProperty;var p=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var f=Object.prototype.hasOwnProperty;var g=(e,t)=>{for(var o in t)c(e,o,{get:t[o],enumerable:!0})},b=(e,t,o,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of h(t))!f.call(e,i)&&i!==o&&c(e,i,{get:()=>t[i],enumerable:!(n=p(t,i))||n.enumerable});return e};var x=e=>b(c({},"__esModule",{value:!0}),e);var A={};g(A,{scanRegions:()=>l,stabilize:()=>m,waitForReady:()=>u});var S=[{selector:"[data-pixeye-mask]",type:"mask"},{selector:"[data-pixeye-ignore]",type:"ignore"},{selector:"[data-pixeye-dynamic]",type:"dynamic"},{selector:'[data-visual-test="blackout"]',type:"mask"},{selector:'[data-visual-test="removed"]',type:"ignore"},{selector:'[data-visual-test="transparent"]',type:"mask"},{selector:"[data-percy-ignore]",type:"ignore"},{selector:"[data-percy-mask]",type:"mask"},{selector:'[data-chromatic="ignore"]',type:"ignore"}],l=()=>{let e=[],t=new Set;for(let{selector:o,type:n}of S){let i=document.querySelectorAll(o);for(let a of i){if(t.has(a))continue;t.add(a);let r=a.getBoundingClientRect();r.width===0||r.height===0||e.push({type:n,selector:o,bounds:{x:Math.max(0,Math.round(r.x)),y:Math.max(0,Math.round(r.y)),width:Math.round(r.width),height:Math.round(r.height)}})}}return e};var d="__pixeye_stabilize",s=e=>{let t=document.getElementById(d);t||(t=document.createElement("style"),t.id=d,document.head.appendChild(t)),t.textContent+=e+`
2
+ `},w=()=>{s(`
3
+ * {
4
+ -webkit-font-smoothing: antialiased !important;
5
+ -moz-osx-font-smoothing: grayscale !important;
6
+ }
7
+ `)},E=()=>{s(`
8
+ *, *::before, *::after {
9
+ animation-duration: 0s !important;
10
+ animation-delay: 0s !important;
11
+ transition-duration: 0s !important;
12
+ transition-delay: 0s !important;
13
+ scroll-behavior: auto !important;
14
+ }
15
+ `)},R=()=>{s(`
16
+ * { caret-color: transparent !important; }
17
+ ::-webkit-scrollbar { display: none !important; }
18
+ * { scrollbar-width: none !important; }
19
+ `),document.querySelectorAll("[contenteditable], textarea, input").forEach(e=>{e.setAttribute("spellcheck","false")})},v=()=>{document.querySelectorAll("img").forEach(e=>{let t=e.getBoundingClientRect(),o=Math.round(t.width),n=Math.round(t.height);(o!==t.width||n!==t.height)&&(e.style.width=`${o}px`,e.style.height=`${n}px`)})},T=()=>{document.querySelectorAll("*").forEach(e=>{let t=getComputedStyle(e);(t.position==="sticky"||t.position==="fixed")&&(e.style.position="absolute")})},m=(e={})=>{e.colorScheme&&(document.documentElement.style.colorScheme=e.colorScheme),e.fontAntialiasing!==!1&&w(),e.disableAnimations!==!1&&E(),e.hideNonDeterministic!==!1&&R(),e.roundImageSizes!==!1&&v(),e.stabilizeStickyFixed!==!1&&T(),e.css&&s(e.css)};var y=async(e,t,o)=>{let n=Date.now();for(;!e();){if(Date.now()-n>o)throw new Error(`[pixeye] waitForReady timed out waiting for: ${t}`);await new Promise(i=>setTimeout(i,100))}},u=async(e={})=>{let t=e.timeout??1e4,o=async()=>{await document.fonts.ready;let i=Array.from(document.querySelectorAll("img"));await Promise.all(i.filter(a=>!a.complete).map(a=>new Promise(r=>{a.addEventListener("load",()=>r(),{once:!0}),a.addEventListener("error",()=>r(),{once:!0})}))),await y(()=>!document.querySelector('[aria-busy="true"]'),'aria-busy="true" elements to clear',t),await y(()=>!document.querySelector("[data-pixeye-loading]"),"data-pixeye-loading elements to disappear",t)},n=new Promise((i,a)=>{setTimeout(()=>a(new Error(`[pixeye] waitForReady timed out after ${t}ms`)),t)});await Promise.race([o(),n])};return x(A);})();
20
+ if(typeof window!=="undefined")window.__pixeye=__pixeye;
@@ -0,0 +1,10 @@
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
+ export declare const transformArgos: (source: string) => string;
@@ -0,0 +1,19 @@
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
+ export const transformArgos = (source) => {
11
+ let result = source;
12
+ // 1. Transform import
13
+ 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
+ result = result.replace(/argosScreenshot\s*\(/g, 'pixeye.screenshot(');
16
+ // 3. Transform option names
17
+ result = result.replace(/argosCSS\s*:/g, 'css:');
18
+ return result;
19
+ };
@@ -0,0 +1,10 @@
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
+ export declare const transformPercy: (source: string) => string;
@@ -0,0 +1,19 @@
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
+ export const transformPercy = (source) => {
11
+ let result = source;
12
+ // 1. Transform import (default import)
13
+ result = result.replace(/import\s+percySnapshot\s+from\s*['"]@percy\/playwright['"]/g, "import { pixeye } from '@pixeyedev/playwright'");
14
+ // 2. Transform function calls
15
+ result = result.replace(/percySnapshot\s*\(/g, 'pixeye.screenshot(');
16
+ // 3. Transform option names
17
+ result = result.replace(/percyCSS\s*:/g, 'css:');
18
+ return result;
19
+ };
@@ -0,0 +1,2 @@
1
+ export { transformArgos } from './from-argos.js';
2
+ export { transformPercy } from './from-percy.js';
@@ -0,0 +1,2 @@
1
+ export { transformArgos } from './from-argos.js';
2
+ export { transformPercy } from './from-percy.js';
@@ -0,0 +1,23 @@
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
+ import { expect } from '@playwright/test';
16
+ import { screenshot, type ScreenshotOptions } from './screenshot.js';
17
+ export interface PixeyeFixture {
18
+ screenshot: (target: Parameters<typeof screenshot>[0], name: string, options?: ScreenshotOptions) => Promise<string>;
19
+ }
20
+ export declare const test: import("playwright/test").TestType<import("playwright/test").PlaywrightTestArgs & import("playwright/test").PlaywrightTestOptions & {
21
+ pixeye: PixeyeFixture;
22
+ }, import("playwright/test").PlaywrightWorkerArgs & import("playwright/test").PlaywrightWorkerOptions>;
23
+ export { expect };
@@ -0,0 +1,34 @@
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
+ import { join } from 'node:path';
16
+ import { expect, test as base } from '@playwright/test';
17
+ import { screenshot, setContext } from './screenshot.js';
18
+ export const test = base.extend({
19
+ // eslint-disable-next-line no-empty-pattern
20
+ pixeye: async ({}, use, testInfo) => {
21
+ const outputDir = join(process.cwd(), '.pixeye', 'screenshots');
22
+ const testFile = testInfo.file ? testInfo.file.replace(process.cwd() + '/', '') : 'unknown';
23
+ setContext({
24
+ outputDir,
25
+ testFile,
26
+ testName: testInfo.title
27
+ });
28
+ const pixeyeApi = {
29
+ screenshot: (target, name, options) => screenshot(target, name, options)
30
+ };
31
+ await use(pixeyeApi);
32
+ }
33
+ });
34
+ export { expect };
@@ -0,0 +1,7 @@
1
+ export type { PixeyeFixture } from './fixture.js';
2
+ export { expect, test } from './fixture.js';
3
+ export type { ScreenshotContext, ScreenshotOptions } from './screenshot.js';
4
+ export { getContext, PIXEYE_BROWSER_ARGS, screenshot, setContext } from './screenshot.js';
5
+ export declare const pixeye: {
6
+ screenshot: (target: import("playwright-core").Page | import("playwright-core").Locator, name: string, options?: import("./screenshot.js").ScreenshotOptions) => Promise<string>;
7
+ };
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { expect, test } from './fixture.js';
2
+ export { getContext, PIXEYE_BROWSER_ARGS, screenshot, setContext } from './screenshot.js';
3
+ // ── Convenience namespace (backwards compat with design docs) ───────────────
4
+ import { screenshot as screenshotFn } from './screenshot.js';
5
+ export const pixeye = { screenshot: screenshotFn };
@@ -0,0 +1,22 @@
1
+ import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
2
+ interface ReporterOptions {
3
+ token?: string;
4
+ server?: string;
5
+ uploadConcurrency?: number;
6
+ }
7
+ declare const detectShard: (config: FullConfig) => {
8
+ index: number;
9
+ total: number;
10
+ };
11
+ declare class PixeyeReporter implements Reporter {
12
+ private options;
13
+ private shard;
14
+ private skipUpload;
15
+ constructor(options?: ReporterOptions);
16
+ onBegin(config: FullConfig, _suite: Suite): void;
17
+ onTestEnd(_test: TestCase, _result: TestResult): void;
18
+ onEnd(_result: FullResult): Promise<void>;
19
+ }
20
+ export default PixeyeReporter;
21
+ export { detectShard };
22
+ export type { ReporterOptions };
@@ -0,0 +1,164 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { detectCIEnvironment, discoverScreenshots, PixeyeClient, PixeyeError, runParallel } from '@pixeyedev/core';
4
+ import { getContext } from './screenshot.js';
5
+ const DEFAULT_UPLOAD_CONCURRENCY = 32;
6
+ const detectShard = (config) => {
7
+ // 1. Playwright config (1-indexed → 0-indexed)
8
+ if (config.shard) {
9
+ return { index: config.shard.current - 1, total: config.shard.total };
10
+ }
11
+ // 2. Environment variables (0-indexed)
12
+ const envIndex = process.env.PIXEYE_SHARD_INDEX;
13
+ const envTotal = process.env.PIXEYE_SHARD_TOTAL;
14
+ if (envIndex !== undefined) {
15
+ return {
16
+ index: parseInt(envIndex, 10),
17
+ total: envTotal ? parseInt(envTotal, 10) : 1
18
+ };
19
+ }
20
+ // 3. Default: no sharding
21
+ return { index: 0, total: 1 };
22
+ };
23
+ const detectRunner = () => {
24
+ return 'playwright';
25
+ };
26
+ class PixeyeReporter {
27
+ options;
28
+ shard = { index: 0, total: 1 };
29
+ skipUpload = false;
30
+ constructor(options = {}) {
31
+ this.options = options;
32
+ }
33
+ onBegin(config, _suite) {
34
+ const token = this.options.token ?? process.env.PIXEYE_TOKEN;
35
+ if (!token) {
36
+ console.warn('[pixeye] No PIXEYE_TOKEN found. Skipping upload. Screenshots will still be captured.');
37
+ this.skipUpload = true;
38
+ return;
39
+ }
40
+ this.shard = detectShard(config);
41
+ }
42
+ onTestEnd(_test, _result) {
43
+ // Screenshots are already saved to disk by pixeye.screenshot().
44
+ // Nothing to do per-test.
45
+ }
46
+ async onEnd(_result) {
47
+ if (this.skipUpload) {
48
+ return;
49
+ }
50
+ const token = this.options.token ?? process.env.PIXEYE_TOKEN;
51
+ const server = this.options.server ?? process.env.PIXEYE_SERVER ?? 'https://pixeye.dev';
52
+ if (!token) {
53
+ return;
54
+ }
55
+ // Discover screenshots saved by pixeye.screenshot()
56
+ const ctx = getContext();
57
+ const outputDir = ctx?.outputDir ?? join(process.cwd(), '.pixeye', 'screenshots');
58
+ let files;
59
+ try {
60
+ files = await discoverScreenshots(outputDir);
61
+ }
62
+ catch {
63
+ console.log('[pixeye] No screenshots found. Nothing to upload.');
64
+ return;
65
+ }
66
+ if (files.length === 0) {
67
+ console.log('[pixeye] No screenshots captured. Nothing to upload.');
68
+ return;
69
+ }
70
+ const ci = detectCIEnvironment();
71
+ const commitSha = process.env.PIXEYE_COMMIT ?? ci?.commit;
72
+ const branch = process.env.PIXEYE_BRANCH ?? ci?.branch;
73
+ if (!commitSha || !branch) {
74
+ console.warn('[pixeye] Could not detect commit SHA or branch. Skipping upload.');
75
+ console.warn('[pixeye] Set PIXEYE_COMMIT and PIXEYE_BRANCH, or run in CI (GitHub Actions / GitLab CI).');
76
+ return;
77
+ }
78
+ const runner = detectRunner();
79
+ const concurrency = this.options.uploadConcurrency ?? DEFAULT_UPLOAD_CONCURRENCY;
80
+ try {
81
+ const client = new PixeyeClient({ serverUrl: server, token });
82
+ // 1. Create build
83
+ const prNumber = process.env.PIXEYE_PR ? parseInt(process.env.PIXEYE_PR, 10) : ci?.prNumber;
84
+ const buildResponse = await client.createBuild({
85
+ projectToken: token,
86
+ commitSha,
87
+ branch,
88
+ prNumber: prNumber && !isNaN(prNumber) ? prNumber : undefined,
89
+ shardCount: this.shard.total,
90
+ runner,
91
+ baseBranch: ci?.baseBranch
92
+ });
93
+ const buildId = buildResponse.build.id;
94
+ // 2. Presign upload URLs
95
+ const hashes = files.map(f => f.fileHash);
96
+ const formats = Object.fromEntries(files.filter(f => f.format === 'webp').map(f => [f.fileHash, 'webp']));
97
+ const presign = await client.presignUpload({
98
+ buildId,
99
+ hashes,
100
+ ...(Object.keys(formats).length > 0 ? { formats } : {})
101
+ });
102
+ if (presign.warnings.length > 0) {
103
+ for (const w of presign.warnings) {
104
+ console.warn(`[pixeye] ${w}`);
105
+ }
106
+ }
107
+ // 3. Upload new files
108
+ const hashUrlPairs = Object.entries(presign.urls);
109
+ const hashToFile = new Map(files.map(f => [f.fileHash, f]));
110
+ let uploaded = 0;
111
+ if (hashUrlPairs.length > 0) {
112
+ await runParallel(hashUrlPairs, concurrency, async ([hash, url]) => {
113
+ const file = hashToFile.get(hash);
114
+ if (!file) {
115
+ return;
116
+ }
117
+ const buffer = await readFile(file.filePath);
118
+ const contentType = file.format === 'webp' ? 'image/webp' : 'image/png';
119
+ await client.uploadFile(url, buffer, contentType);
120
+ uploaded++;
121
+ });
122
+ }
123
+ // 4. Read metadata sidecars and build manifest
124
+ const manifest = await Promise.all(files.map(async (f) => {
125
+ // Try to read companion .meta.json
126
+ const metaPath = f.filePath.replace(/\.(png|webp)$/, '.meta.json');
127
+ let metadata;
128
+ try {
129
+ const metaContent = await readFile(metaPath, 'utf-8');
130
+ metadata = JSON.parse(metaContent);
131
+ }
132
+ catch {
133
+ // No sidecar — that's fine
134
+ }
135
+ return {
136
+ name: f.name,
137
+ fileHash: f.fileHash,
138
+ format: f.format,
139
+ width: f.width,
140
+ height: f.height,
141
+ metadata
142
+ };
143
+ }));
144
+ // 5. Submit shard
145
+ await client.submitShard({
146
+ buildId,
147
+ shardIndex: this.shard.index,
148
+ screenshots: manifest
149
+ });
150
+ console.log(`[pixeye] ${files.length} screenshots (${uploaded} new, ${presign.existing.length} cached) — ${server}/builds/${buildId}`);
151
+ }
152
+ catch (err) {
153
+ // Upload failure should NOT fail the test run
154
+ if (err instanceof PixeyeError) {
155
+ console.warn(`[pixeye] Upload failed (${err.status}): ${err.message}`);
156
+ }
157
+ else {
158
+ console.warn(`[pixeye] Upload failed: ${err.message}`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ export default PixeyeReporter;
164
+ export { detectShard };
@@ -0,0 +1,33 @@
1
+ import type { Locator, Page } from '@playwright/test';
2
+ /** Browser args recommended for deterministic screenshots. */
3
+ export declare const PIXEYE_BROWSER_ARGS: string[];
4
+ export interface ScreenshotOptions {
5
+ fullPage?: boolean;
6
+ clip?: {
7
+ x: number;
8
+ y: number;
9
+ w: number;
10
+ h: number;
11
+ };
12
+ mask?: (string | Locator)[];
13
+ ignore?: {
14
+ x: number;
15
+ y: number;
16
+ w: number;
17
+ h: number;
18
+ }[];
19
+ css?: string;
20
+ waitFor?: string;
21
+ waitForTimeout?: number;
22
+ retries?: number;
23
+ threshold?: number;
24
+ colorScheme?: 'light' | 'dark';
25
+ }
26
+ export interface ScreenshotContext {
27
+ outputDir: string;
28
+ testFile: string;
29
+ testName: string;
30
+ }
31
+ export declare const setContext: (ctx: ScreenshotContext) => void;
32
+ export declare const getContext: () => ScreenshotContext | null;
33
+ export declare const screenshot: (target: Page | Locator, name: string, options?: ScreenshotOptions) => Promise<string>;
@@ -0,0 +1,211 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /** Browser args recommended for deterministic screenshots. */
6
+ export const PIXEYE_BROWSER_ARGS = [
7
+ '--force-device-scale-factor=1',
8
+ '--font-render-hinting=none',
9
+ '--disable-lcd-text'
10
+ ];
11
+ // Shared context set by the reporter or test setup
12
+ let _context = null;
13
+ export const setContext = (ctx) => {
14
+ _context = ctx;
15
+ };
16
+ 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
+ const THIS_DIR = dirname(fileURLToPath(import.meta.url));
21
+ let _iifeCache = null;
22
+ const loadIIFE = async () => {
23
+ if (_iifeCache) {
24
+ return _iifeCache;
25
+ }
26
+ _iifeCache = await readFile(join(THIS_DIR, 'browser.iife.js'), 'utf-8');
27
+ return _iifeCache;
28
+ };
29
+ // ── Helpers ──────────────────────────────────────────────────────────────────
30
+ const injectBrowserScripts = async (page) => {
31
+ 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');
36
+ if (!alreadyInjected) {
37
+ await page.evaluate(iife);
38
+ }
39
+ };
40
+ const getPage = (target) => {
41
+ if ('page' in target && typeof target.page === 'function') {
42
+ return target.page();
43
+ }
44
+ return target;
45
+ };
46
+ const isLocator = (target) => {
47
+ return 'page' in target && typeof target.page === 'function';
48
+ };
49
+ const sanitizePath = (str) => {
50
+ return str.replace(/[^a-zA-Z0-9._/-]/g, '_');
51
+ };
52
+ // ── Mask resolution ──────────────────────────────────────────────────────────
53
+ const resolveMaskRegions = async (page, masks) => {
54
+ const regions = [];
55
+ for (const selectorOrLocator of masks) {
56
+ if (typeof selectorOrLocator === 'string') {
57
+ const bounds = await page.evaluate((sel) => {
58
+ const el = document.querySelector(sel);
59
+ if (!el) {
60
+ return null;
61
+ }
62
+ const rect = el.getBoundingClientRect();
63
+ return {
64
+ x: Math.max(0, Math.round(rect.x)),
65
+ y: Math.max(0, Math.round(rect.y)),
66
+ width: Math.round(rect.width),
67
+ height: Math.round(rect.height)
68
+ };
69
+ }, selectorOrLocator);
70
+ if (bounds && bounds.width > 0 && bounds.height > 0) {
71
+ regions.push({ type: 'mask', selector: selectorOrLocator, bounds });
72
+ }
73
+ }
74
+ else {
75
+ // Locator
76
+ const box = await selectorOrLocator.boundingBox();
77
+ if (box) {
78
+ regions.push({
79
+ type: 'mask',
80
+ selector: String(selectorOrLocator),
81
+ bounds: {
82
+ x: Math.max(0, Math.round(box.x)),
83
+ y: Math.max(0, Math.round(box.y)),
84
+ width: Math.round(box.width),
85
+ height: Math.round(box.height)
86
+ }
87
+ });
88
+ }
89
+ }
90
+ }
91
+ return regions;
92
+ };
93
+ // ── Capture with retry ───────────────────────────────────────────────────────
94
+ const RETRY_DELAY_MS = 100;
95
+ const captureOnce = async (target, options) => {
96
+ const screenshotOpts = {};
97
+ if (options.fullPage) {
98
+ screenshotOpts.fullPage = true;
99
+ }
100
+ if (options.clip) {
101
+ screenshotOpts.clip = {
102
+ x: options.clip.x,
103
+ y: options.clip.y,
104
+ width: options.clip.w,
105
+ height: options.clip.h
106
+ };
107
+ }
108
+ if (isLocator(target)) {
109
+ return (await target.screenshot(screenshotOpts));
110
+ }
111
+ return (await target.screenshot(screenshotOpts));
112
+ };
113
+ const captureStable = async (target, options) => {
114
+ const retries = options.retries ?? 0;
115
+ if (retries <= 0) {
116
+ return captureOnce(target, options);
117
+ }
118
+ // Capture retries+1 times, find first consecutive duplicate (stable frame)
119
+ const captures = [];
120
+ const page = getPage(target);
121
+ for (let i = 0; i <= retries; i++) {
122
+ captures.push(await captureOnce(target, options));
123
+ if (i < retries) {
124
+ await page.waitForTimeout(RETRY_DELAY_MS);
125
+ }
126
+ }
127
+ const hashes = captures.map(b => createHash('sha256').update(b).digest('hex'));
128
+ for (let i = 1; i < hashes.length; i++) {
129
+ if (hashes[i] === hashes[i - 1]) {
130
+ return captures[i];
131
+ }
132
+ }
133
+ // No stable frame found — return the last capture
134
+ return captures[captures.length - 1];
135
+ };
136
+ // ── Main screenshot function ─────────────────────────────────────────────────
137
+ export const screenshot = async (target, name, options = {}) => {
138
+ const page = getPage(target);
139
+ // Determine output directory
140
+ const outputDir = _context?.outputDir ?? join(process.cwd(), '.pixeye', 'screenshots');
141
+ const testFile = _context?.testFile ?? 'unknown';
142
+ const testName = _context?.testName ?? '';
143
+ const sanitizedTestFile = sanitizePath(testFile);
144
+ const dir = join(outputDir, sanitizedTestFile);
145
+ await mkdir(dir, { recursive: true });
146
+ const pngPath = join(dir, `${name}.png`);
147
+ const metaPath = join(dir, `${name}.meta.json`);
148
+ // 1. Inject browser scripts
149
+ await injectBrowserScripts(page);
150
+ // 2. Stabilize the page
151
+ await page.evaluate((opts) => {
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
+ window.__pixeye.stabilize({
154
+ css: opts.css,
155
+ colorScheme: opts.colorScheme
156
+ });
157
+ }, { css: options.css, colorScheme: options.colorScheme });
158
+ // 3. Wait for page to be ready
159
+ await page.evaluate((timeout) => {
160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
+ return window.__pixeye.waitForReady({ timeout });
162
+ }, options.waitForTimeout ?? 10_000);
163
+ // 4. Custom waitFor selector
164
+ if (options.waitFor) {
165
+ await page.waitForSelector(options.waitFor, {
166
+ timeout: options.waitForTimeout ?? 10_000
167
+ });
168
+ }
169
+ // 5. Scan DOM for region attributes
170
+ const scannedRegions = await page.evaluate(() => {
171
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
+ return window.__pixeye.scanRegions();
173
+ });
174
+ // 6. Resolve mask selectors/locators to region bounds
175
+ const maskRegions = options.mask ? await resolveMaskRegions(page, options.mask) : [];
176
+ const allRegions = [...scannedRegions, ...maskRegions];
177
+ // 7. Capture screenshot (with optional retry for stability)
178
+ const buffer = await captureStable(target, options);
179
+ // 8. Get component bounds for element-level screenshots
180
+ let componentBounds = null;
181
+ if (isLocator(target)) {
182
+ const box = await target.boundingBox();
183
+ if (box) {
184
+ componentBounds = {
185
+ x: Math.round(box.x),
186
+ y: Math.round(box.y),
187
+ width: Math.round(box.width),
188
+ height: Math.round(box.height)
189
+ };
190
+ }
191
+ }
192
+ // 9. Gather viewport + browser info
193
+ const viewportSize = page.viewportSize();
194
+ const browserName = page.context().browser()?.browserType().name() ?? 'unknown';
195
+ // 10. Build metadata sidecar
196
+ const metadata = {
197
+ version: 1,
198
+ viewport: viewportSize ? { width: viewportSize.width, height: viewportSize.height } : undefined,
199
+ browser: browserName,
200
+ colorScheme: options.colorScheme,
201
+ testFile,
202
+ testName,
203
+ threshold: options.threshold,
204
+ regions: allRegions.length > 0 ? allRegions : undefined,
205
+ componentBounds
206
+ };
207
+ // 11. Write .png and .meta.json
208
+ await writeFile(pngPath, buffer);
209
+ await writeFile(metaPath, JSON.stringify(metadata, null, 2) + '\n');
210
+ return pngPath;
211
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@pixeyedev/playwright",
3
+ "version": "0.4.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./reporter": {
17
+ "types": "./src/reporter.ts",
18
+ "import": "./dist/reporter.js"
19
+ },
20
+ "./codemods": {
21
+ "types": "./src/codemods/index.ts",
22
+ "import": "./dist/codemods/index.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsc && cp ../browser/dist/browser.iife.js dist/browser.iife.js",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "vitest run",
29
+ "lint": "echo 'ok'",
30
+ "ai-check": "exit 0"
31
+ },
32
+ "dependencies": {
33
+ "@pixeyedev/core": "^0.4.0",
34
+ "@pixeyedev/shared": "^0.4.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@playwright/test": ">=1.40.0"
38
+ },
39
+ "devDependencies": {
40
+ "@playwright/test": "^1.58.2",
41
+ "@types/node": "^22.0.0",
42
+ "typescript": "^5.7.0"
43
+ }
44
+ }