@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.
- package/dist/browser.iife.js +20 -0
- package/dist/codemods/from-argos.d.ts +10 -0
- package/dist/codemods/from-argos.js +19 -0
- package/dist/codemods/from-percy.d.ts +10 -0
- package/dist/codemods/from-percy.js +19 -0
- package/dist/codemods/index.d.ts +2 -0
- package/dist/codemods/index.js +2 -0
- package/dist/fixture.d.ts +23 -0
- package/dist/fixture.js +34 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/reporter.d.ts +22 -0
- package/dist/reporter.js +164 -0
- package/dist/screenshot.d.ts +33 -0
- package/dist/screenshot.js +211 -0
- package/package.json +44 -0
|
@@ -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,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 };
|
package/dist/fixture.js
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|
package/dist/reporter.js
ADDED
|
@@ -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
|
+
}
|