@juangadm/pre-post 0.3.0 → 1.1.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 +174 -233
- package/dist/baseline.d.ts +119 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +316 -0
- package/dist/baseline.js.map +1 -0
- package/dist/bin/cli.js +243 -458
- package/dist/bin/cli.js.map +1 -1
- package/dist/browser.d.ts +68 -25
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +500 -105
- package/dist/browser.js.map +1 -1
- package/dist/capture.d.ts +1 -6
- package/dist/capture.d.ts.map +1 -1
- package/dist/capture.js +12 -36
- package/dist/capture.js.map +1 -1
- package/dist/commands/compare.d.ts +18 -0
- package/dist/commands/compare.d.ts.map +1 -0
- package/dist/commands/compare.js +82 -0
- package/dist/commands/compare.js.map +1 -0
- package/dist/commands/detect.d.ts +25 -0
- package/dist/commands/detect.d.ts.map +1 -0
- package/dist/commands/detect.js +22 -0
- package/dist/commands/detect.js.map +1 -0
- package/dist/commands/doctor.d.ts +42 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +119 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/login.d.ts +6 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +34 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/pr.d.ts +32 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +259 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/prune.d.ts +10 -0
- package/dist/commands/prune.d.ts.map +1 -0
- package/dist/commands/prune.js +13 -0
- package/dist/commands/prune.js.map +1 -0
- package/dist/comparison.d.ts +104 -0
- package/dist/comparison.d.ts.map +1 -0
- package/dist/comparison.js +236 -0
- package/dist/comparison.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +82 -0
- package/dist/config.js.map +1 -0
- package/dist/deployments.d.ts +71 -0
- package/dist/deployments.d.ts.map +1 -0
- package/dist/deployments.js +204 -0
- package/dist/deployments.js.map +1 -0
- package/dist/diff-pool.d.ts +17 -0
- package/dist/diff-pool.d.ts.map +1 -0
- package/dist/diff-pool.js +92 -0
- package/dist/diff-pool.js.map +1 -0
- package/dist/diff-worker.d.ts +32 -0
- package/dist/diff-worker.d.ts.map +1 -0
- package/dist/diff-worker.js +39 -0
- package/dist/diff-worker.js.map +1 -0
- package/dist/diff.d.ts +47 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +281 -0
- package/dist/diff.js.map +1 -0
- package/dist/doctor.d.ts +79 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +138 -0
- package/dist/doctor.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +59 -0
- package/dist/errors.js.map +1 -0
- package/dist/git.d.ts +61 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +182 -0
- package/dist/git.js.map +1 -0
- package/dist/github.d.ts +163 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +411 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +22 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -67
- package/dist/index.js.map +1 -1
- package/dist/landing.d.ts +48 -0
- package/dist/landing.d.ts.map +1 -0
- package/dist/landing.js +93 -0
- package/dist/landing.js.map +1 -0
- package/dist/pkg.d.ts +17 -0
- package/dist/pkg.d.ts.map +1 -0
- package/dist/pkg.js +49 -0
- package/dist/pkg.js.map +1 -0
- package/dist/report.d.ts +20 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +130 -0
- package/dist/report.js.map +1 -0
- package/dist/routes/imports.d.ts +46 -0
- package/dist/routes/imports.d.ts.map +1 -0
- package/dist/routes/imports.js +208 -0
- package/dist/routes/imports.js.map +1 -0
- package/dist/routes/vite.d.ts +15 -0
- package/dist/routes/vite.d.ts.map +1 -0
- package/dist/routes/vite.js +130 -0
- package/dist/routes/vite.js.map +1 -0
- package/dist/routes.d.ts +65 -11
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +346 -141
- package/dist/routes.js.map +1 -1
- package/dist/run.d.ts +116 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +316 -0
- package/dist/run.js.map +1 -0
- package/dist/sameness.d.ts +91 -0
- package/dist/sameness.d.ts.map +1 -0
- package/dist/sameness.js +157 -0
- package/dist/sameness.js.map +1 -0
- package/dist/sessions.d.ts +50 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +80 -0
- package/dist/sessions.js.map +1 -0
- package/dist/shift.d.ts +67 -0
- package/dist/shift.d.ts.map +1 -0
- package/dist/shift.js +184 -0
- package/dist/shift.js.map +1 -0
- package/dist/types.d.ts +179 -68
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/dist/url.d.ts +12 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +30 -0
- package/dist/url.js.map +1 -0
- package/dist/viewport.d.ts +6 -1
- package/dist/viewport.d.ts.map +1 -1
- package/dist/viewport.js +13 -7
- package/dist/viewport.js.map +1 -1
- package/package.json +21 -14
- package/skill/SKILL.md +56 -221
- package/dist/clipboard.d.ts +0 -9
- package/dist/clipboard.d.ts.map +0 -1
- package/dist/clipboard.js +0 -26
- package/dist/clipboard.js.map +0 -1
- package/dist/upload.d.ts +0 -39
- package/dist/upload.d.ts.map +0 -1
- package/dist/upload.js +0 -159
- package/dist/upload.js.map +0 -1
- package/dist/video.d.ts +0 -19
- package/dist/video.d.ts.map +0 -1
- package/dist/video.js +0 -263
- package/dist/video.js.map +0 -1
- package/skill/scripts/adapters/0x0st.sh +0 -36
- package/skill/scripts/adapters/blob.sh +0 -55
- package/skill/scripts/adapters/git-native.sh +0 -51
- package/skill/scripts/upload-and-copy.sh +0 -230
package/dist/browser.js
CHANGED
|
@@ -1,55 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser automation via Playwright.
|
|
3
|
-
*
|
|
2
|
+
* Browser automation via Playwright (playwright-core + Chromium headless shell).
|
|
3
|
+
*
|
|
4
|
+
* One browser per process, a fresh context per capture, and a small page pool.
|
|
5
|
+
* Captures are deterministic: the page's own timeline is held still and
|
|
6
|
+
* advanced by a fixed budget, reduced motion, animations finished, caret
|
|
7
|
+
* hidden, fonts and images settled, layout stable.
|
|
4
8
|
*/
|
|
5
|
-
import { chromium } from 'playwright';
|
|
9
|
+
import { chromium } from 'playwright-core';
|
|
6
10
|
import fs from 'fs';
|
|
7
11
|
import path from 'path';
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
import { BrowserNotFoundError, HttpStatusError, NavigationError, isVercelResponse } from './errors.js';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
/** Every capture sees the same wall clock, so dates and "x minutes ago" never drift. */
|
|
17
|
+
export const FIXED_TIME = new Date('2026-01-15T12:00:00.000Z');
|
|
18
|
+
/**
|
|
19
|
+
* How much of the page's own timeline to run before capturing.
|
|
20
|
+
*
|
|
21
|
+
* The clock is frozen while the page loads, so this is the *only* time a
|
|
22
|
+
* timer-driven animation gets, and it is the same on both sides of a
|
|
23
|
+
* comparison however fast each host answered. Long enough for entrance
|
|
24
|
+
* animations to land, short enough to stay cheap.
|
|
25
|
+
*/
|
|
26
|
+
export const TIMELINE_BUDGET_MS = 600;
|
|
27
|
+
/** One animation frame of that budget. */
|
|
28
|
+
const FRAME_MS = 16;
|
|
29
|
+
/** Real-time gap between layout-stability polls (page timers are frozen). */
|
|
30
|
+
const STABILITY_POLL_MS = 30;
|
|
31
|
+
/** Hosts that are always served from this machine. */
|
|
32
|
+
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1'];
|
|
33
|
+
const MAX_CONCURRENT_PAGES = Number(process.env.PRE_POST_CONCURRENCY) || 6;
|
|
34
|
+
const NAVIGATION_TIMEOUT = 30_000;
|
|
8
35
|
let browser = null;
|
|
9
|
-
|
|
36
|
+
let launching = null;
|
|
37
|
+
let browserLabel = '';
|
|
10
38
|
let activePages = 0;
|
|
11
39
|
const pageQueue = [];
|
|
12
|
-
|
|
13
|
-
* Get the shared Browser instance (creating it if needed).
|
|
14
|
-
* Used by video.ts to create fresh pages without animation-killing CSS.
|
|
15
|
-
*/
|
|
16
|
-
export async function getBrowser() {
|
|
17
|
-
if (!browser) {
|
|
18
|
-
browser = await launchBrowser();
|
|
19
|
-
}
|
|
20
|
-
return browser;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Acquire a page from the pool. Blocks if MAX_CONCURRENT_PAGES are in use.
|
|
24
|
-
* Each page gets its own viewport and deviceScaleFactor.
|
|
25
|
-
*/
|
|
26
|
-
export async function acquirePage(viewport) {
|
|
40
|
+
async function acquireSlot() {
|
|
27
41
|
if (activePages >= MAX_CONCURRENT_PAGES) {
|
|
28
42
|
await new Promise(resolve => pageQueue.push(resolve));
|
|
29
43
|
}
|
|
30
44
|
activePages++;
|
|
31
|
-
const b = await getBrowser();
|
|
32
|
-
return b.newPage({ viewport, deviceScaleFactor: 2 });
|
|
33
45
|
}
|
|
34
|
-
|
|
35
|
-
* Release a page back to the pool (closes it).
|
|
36
|
-
*/
|
|
37
|
-
export async function releasePage(pg) {
|
|
46
|
+
function releaseSlot() {
|
|
38
47
|
activePages--;
|
|
39
|
-
if (!pg.isClosed())
|
|
40
|
-
await pg.close();
|
|
41
48
|
const next = pageQueue.shift();
|
|
42
49
|
if (next)
|
|
43
50
|
next();
|
|
44
51
|
}
|
|
52
|
+
// ============================================================
|
|
53
|
+
// Launch
|
|
54
|
+
// ============================================================
|
|
55
|
+
/** Directory of the playwright-core package (for its CLI and browsers.json). */
|
|
56
|
+
export function playwrightCoreDir() {
|
|
57
|
+
return path.dirname(require.resolve('playwright-core/package.json'));
|
|
58
|
+
}
|
|
45
59
|
/**
|
|
46
|
-
* Scan Playwright's cache
|
|
47
|
-
* Returns paths to try, in order of preference.
|
|
60
|
+
* Scan Playwright's browser cache for installed Chromium executables.
|
|
48
61
|
*/
|
|
49
62
|
function findCachedChromium() {
|
|
50
63
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
51
64
|
const cacheDir = process.env.PLAYWRIGHT_BROWSERS_PATH
|
|
52
|
-
||
|
|
65
|
+
|| (process.platform === 'darwin'
|
|
66
|
+
? path.join(home, 'Library', 'Caches', 'ms-playwright')
|
|
67
|
+
: process.platform === 'win32'
|
|
68
|
+
? path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ms-playwright')
|
|
69
|
+
: path.join(home, '.cache', 'ms-playwright'));
|
|
53
70
|
let entries;
|
|
54
71
|
try {
|
|
55
72
|
entries = fs.readdirSync(cacheDir);
|
|
@@ -57,41 +74,92 @@ function findCachedChromium() {
|
|
|
57
74
|
catch {
|
|
58
75
|
return [];
|
|
59
76
|
}
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
const subpaths = process.platform === 'darwin'
|
|
78
|
+
? [
|
|
79
|
+
path.join('chrome-mac', 'headless_shell'),
|
|
80
|
+
path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
|
81
|
+
path.join('chrome-mac-arm64', 'headless_shell'),
|
|
82
|
+
path.join('chrome-mac-arm64', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
|
83
|
+
]
|
|
84
|
+
: process.platform === 'win32'
|
|
85
|
+
? [path.join('chrome-win', 'headless_shell.exe'), path.join('chrome-win', 'chrome.exe')]
|
|
86
|
+
: [
|
|
87
|
+
path.join('chrome-linux', 'headless_shell'),
|
|
88
|
+
path.join('chrome-headless-shell-linux64', 'chrome-headless-shell'),
|
|
89
|
+
path.join('chrome-linux', 'chrome'),
|
|
90
|
+
];
|
|
91
|
+
// Prefer headless shell builds, newest first.
|
|
92
|
+
const sorted = entries
|
|
93
|
+
.filter(e => e.startsWith('chromium'))
|
|
94
|
+
.sort((a, b) => (b.includes('headless') ? 1 : 0) - (a.includes('headless') ? 1 : 0) || b.localeCompare(a));
|
|
70
95
|
const candidates = [];
|
|
71
|
-
for (const entry of
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (fs.existsSync(fullPath))
|
|
77
|
-
candidates.push(fullPath);
|
|
96
|
+
for (const entry of sorted) {
|
|
97
|
+
for (const sub of subpaths) {
|
|
98
|
+
const full = path.join(cacheDir, entry, sub);
|
|
99
|
+
if (fs.existsSync(full))
|
|
100
|
+
candidates.push(full);
|
|
78
101
|
}
|
|
79
102
|
}
|
|
80
103
|
return candidates;
|
|
81
104
|
}
|
|
105
|
+
const LAUNCH_ARGS = [
|
|
106
|
+
'--disable-dev-shm-usage',
|
|
107
|
+
'--hide-scrollbars',
|
|
108
|
+
'--disable-background-timer-throttling',
|
|
109
|
+
'--disable-renderer-backgrounding',
|
|
110
|
+
'--force-color-profile=srgb',
|
|
111
|
+
'--font-render-hinting=none',
|
|
112
|
+
];
|
|
113
|
+
/**
|
|
114
|
+
* Proxy settings for the capture browser, from the environment.
|
|
115
|
+
*
|
|
116
|
+
* Node's fetch and Chromium each ignore the standard proxy variables unless
|
|
117
|
+
* told, so on a corporate network or in a sandboxed container the probes can
|
|
118
|
+
* succeed while every capture times out.
|
|
119
|
+
*
|
|
120
|
+
* Playwright appends `<-loopback>` to Chromium's bypass list whenever a launch
|
|
121
|
+
* proxy is set, which forces localhost *through* the proxy however NO_PROXY is
|
|
122
|
+
* written. A proxy that refuses localhost then serves its own error page on
|
|
123
|
+
* both sides of a comparison, the diff comes out byte-identical, and the run
|
|
124
|
+
* reports "no visual changes" for a PR that changed plenty. So loopback is
|
|
125
|
+
* named in the bypass list and the forcing is switched off at launch.
|
|
126
|
+
*/
|
|
127
|
+
export function proxyFromEnv(env = process.env) {
|
|
128
|
+
const server = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy;
|
|
129
|
+
if (!server)
|
|
130
|
+
return undefined;
|
|
131
|
+
const noProxy = env.NO_PROXY || env.no_proxy;
|
|
132
|
+
// Playwright wants a comma-separated bypass list and always resolves
|
|
133
|
+
// loopback directly, so only non-empty custom entries are worth passing.
|
|
134
|
+
const entries = (noProxy ?? '').split(',').map(h => h.trim()).filter(Boolean);
|
|
135
|
+
// Loopback is always direct: it is a dev server on this machine, never
|
|
136
|
+
// something the proxy could route to.
|
|
137
|
+
for (const host of LOOPBACK_HOSTS)
|
|
138
|
+
if (!entries.includes(host))
|
|
139
|
+
entries.push(host);
|
|
140
|
+
return { server, bypass: entries.join(',') };
|
|
141
|
+
}
|
|
82
142
|
/**
|
|
83
143
|
* Launch Chromium with a fallback chain:
|
|
84
|
-
* 1. Explicit custom path (PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) —
|
|
85
|
-
* 2.
|
|
86
|
-
* 3.
|
|
87
|
-
* 4.
|
|
144
|
+
* 1. Explicit custom path (PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) — override, fails hard
|
|
145
|
+
* 2. Bundled Playwright Chromium (headless shell when headless)
|
|
146
|
+
* 3. Any Chromium build found in Playwright's cache
|
|
147
|
+
* 4. System Chrome / Edge
|
|
88
148
|
*/
|
|
89
|
-
async function launchBrowser() {
|
|
90
|
-
|
|
149
|
+
export async function launchBrowser(opts = {}) {
|
|
150
|
+
const headless = opts.headless ?? true;
|
|
151
|
+
const proxy = proxyFromEnv();
|
|
152
|
+
// Checked in Playwright's own process, so it has to be set here rather than
|
|
153
|
+
// passed to the browser. Without it the bypass list above is ignored for
|
|
154
|
+
// loopback.
|
|
155
|
+
if (proxy)
|
|
156
|
+
process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK = '1';
|
|
91
157
|
const customPath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
|
92
158
|
if (customPath) {
|
|
93
159
|
try {
|
|
94
|
-
|
|
160
|
+
const b = await chromium.launch({ headless, executablePath: customPath, args: LAUNCH_ARGS, proxy });
|
|
161
|
+
browserLabel = `custom (${customPath})`;
|
|
162
|
+
return b;
|
|
95
163
|
}
|
|
96
164
|
catch (err) {
|
|
97
165
|
throw new Error(`Failed to launch Chromium at custom path: ${customPath}\n` +
|
|
@@ -99,82 +167,409 @@ async function launchBrowser() {
|
|
|
99
167
|
'Either fix the path or unset PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to use auto-detection.');
|
|
100
168
|
}
|
|
101
169
|
}
|
|
102
|
-
// Auto-detection: try each strategy in order
|
|
103
170
|
const strategies = [
|
|
104
|
-
{ label: '
|
|
105
|
-
{ label: 'Bundled Playwright Chromium', options: { headless: true } },
|
|
171
|
+
{ label: 'bundled', options: { headless, args: LAUNCH_ARGS, proxy } },
|
|
106
172
|
];
|
|
107
|
-
|
|
108
|
-
|
|
173
|
+
if (headless) {
|
|
174
|
+
for (const cachedPath of findCachedChromium()) {
|
|
175
|
+
strategies.push({ label: `cached (${cachedPath})`, options: { headless, executablePath: cachedPath, args: LAUNCH_ARGS, proxy } });
|
|
176
|
+
}
|
|
109
177
|
}
|
|
110
|
-
|
|
178
|
+
strategies.push({ label: 'system chrome', options: { headless, channel: 'chrome', args: LAUNCH_ARGS, proxy } });
|
|
179
|
+
strategies.push({ label: 'system edge', options: { headless, channel: 'msedge', args: LAUNCH_ARGS, proxy } });
|
|
180
|
+
let lastError = null;
|
|
181
|
+
for (const { label, options } of strategies) {
|
|
111
182
|
try {
|
|
112
|
-
|
|
183
|
+
const b = await chromium.launch(options);
|
|
184
|
+
browserLabel = label;
|
|
185
|
+
return b;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
lastError = err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw new BrowserNotFoundError(lastError);
|
|
192
|
+
}
|
|
193
|
+
/** Install a browser through playwright-core's CLI. Returns true on success. */
|
|
194
|
+
export function installBrowser(kind) {
|
|
195
|
+
const cli = path.join(playwrightCoreDir(), 'cli.js');
|
|
196
|
+
console.error(`Installing ${kind} (one-time, ~${kind === 'chromium' ? '170' : '80'} MB)...`);
|
|
197
|
+
return spawnSync(process.execPath, [cli, 'install', kind], { stdio: 'inherit' }).status === 0;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Launch, installing the right browser first if none is found.
|
|
201
|
+
* Throws BrowserNotFoundError (with `installed` set) when it still cannot launch.
|
|
202
|
+
*/
|
|
203
|
+
export async function launchBrowserOrInstall(opts = {}) {
|
|
204
|
+
try {
|
|
205
|
+
return await launchBrowser(opts);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (!(err instanceof BrowserNotFoundError))
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
const installed = installBrowser(opts.headless === false ? 'chromium' : 'chromium-headless-shell');
|
|
212
|
+
if (!installed)
|
|
213
|
+
throw new BrowserNotFoundError(null, false);
|
|
214
|
+
try {
|
|
215
|
+
return await launchBrowser(opts);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (err instanceof BrowserNotFoundError)
|
|
219
|
+
throw new BrowserNotFoundError(err.cause, true);
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** The process-wide headless browser, launched (and installed) on first use. */
|
|
224
|
+
export async function getBrowser() {
|
|
225
|
+
if (browser)
|
|
226
|
+
return browser;
|
|
227
|
+
// Captures start concurrently, so the launch has to be shared from the first
|
|
228
|
+
// call rather than from the first one to finish — otherwise every caller
|
|
229
|
+
// sees a null `browser` and launches a Chromium of its own, and all but the
|
|
230
|
+
// last are left running with nothing referencing them.
|
|
231
|
+
if (!launching) {
|
|
232
|
+
launching = launchBrowserOrInstall()
|
|
233
|
+
.then(b => {
|
|
234
|
+
browser = b;
|
|
235
|
+
b.on('disconnected', () => { browser = null; launching = null; });
|
|
236
|
+
return b;
|
|
237
|
+
})
|
|
238
|
+
.catch(err => { launching = null; throw err; });
|
|
239
|
+
}
|
|
240
|
+
return launching;
|
|
241
|
+
}
|
|
242
|
+
export function browserDescription() {
|
|
243
|
+
return browserLabel;
|
|
244
|
+
}
|
|
245
|
+
// ============================================================
|
|
246
|
+
// Contexts
|
|
247
|
+
// ============================================================
|
|
248
|
+
const INIT_SCRIPT = `
|
|
249
|
+
(() => {
|
|
250
|
+
// Deterministic pseudo-random for pages that seed layout from Math.random().
|
|
251
|
+
let seed = 42;
|
|
252
|
+
Math.random = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; };
|
|
253
|
+
// No smooth scrolling: scrollTo() must land immediately.
|
|
254
|
+
const style = document.createElement('style');
|
|
255
|
+
style.setAttribute('data-pre-post', '');
|
|
256
|
+
style.textContent = 'html, body, * { scroll-behavior: auto !important; } ::-webkit-scrollbar { display: none !important; }';
|
|
257
|
+
const attach = () => { (document.head || document.documentElement).appendChild(style); };
|
|
258
|
+
if (document.head) attach(); else document.addEventListener('DOMContentLoaded', attach, { once: true });
|
|
259
|
+
})();
|
|
260
|
+
`;
|
|
261
|
+
/**
|
|
262
|
+
* A context per capture, not per viewport.
|
|
263
|
+
*
|
|
264
|
+
* Playwright's clock belongs to the browser context and is replayed into every
|
|
265
|
+
* page opened in it, so pages that share a context do not share a starting
|
|
266
|
+
* point: the second page inherits the timeline the first one ran. An animated
|
|
267
|
+
* page then lands on a different frame depending on how many captures came
|
|
268
|
+
* before it. One context per capture makes every page start from the same
|
|
269
|
+
* paused instant — and keeps storage and caches from leaking between the two
|
|
270
|
+
* sides of a comparison.
|
|
271
|
+
*/
|
|
272
|
+
async function createContext(viewport, scale, auth) {
|
|
273
|
+
const b = await getBrowser();
|
|
274
|
+
const ctx = await b.newContext({
|
|
275
|
+
viewport,
|
|
276
|
+
deviceScaleFactor: scale,
|
|
277
|
+
reducedMotion: 'reduce',
|
|
278
|
+
colorScheme: 'light',
|
|
279
|
+
locale: 'en-US',
|
|
280
|
+
timezoneId: 'UTC',
|
|
281
|
+
ignoreHTTPSErrors: true,
|
|
282
|
+
serviceWorkers: 'block',
|
|
283
|
+
extraHTTPHeaders: auth?.headers,
|
|
284
|
+
bypassCSP: true,
|
|
285
|
+
});
|
|
286
|
+
// Fake the page's timers and hold them still. `setFixedTime` would only pin
|
|
287
|
+
// what the page *reads* from Date.now(); setTimeout, setInterval and
|
|
288
|
+
// requestAnimationFrame would keep firing, so a timer-driven animation would
|
|
289
|
+
// land on whatever frame the network happened to deliver. With the clock
|
|
290
|
+
// installed and paused, the page's timeline does not move until
|
|
291
|
+
// `advanceTimeline` moves it — by the same amount on both sides.
|
|
292
|
+
await ctx.clock.install({ time: FIXED_TIME });
|
|
293
|
+
await ctx.clock.pauseAt(FIXED_TIME);
|
|
294
|
+
await ctx.addInitScript(INIT_SCRIPT);
|
|
295
|
+
if (auth?.cookies?.length) {
|
|
296
|
+
await ctx.addCookies(auth.cookies.map(c => ({
|
|
297
|
+
name: c.name,
|
|
298
|
+
value: c.value,
|
|
299
|
+
...(c.url ? { url: c.url } : { domain: c.domain, path: c.path || '/' }),
|
|
300
|
+
})));
|
|
301
|
+
}
|
|
302
|
+
ctx.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT);
|
|
303
|
+
ctx.setDefaultTimeout(10_000);
|
|
304
|
+
return ctx;
|
|
305
|
+
}
|
|
306
|
+
// ============================================================
|
|
307
|
+
// Settle
|
|
308
|
+
// ============================================================
|
|
309
|
+
/**
|
|
310
|
+
* Wait for everything that arrives on the *real* clock: fonts, images, the
|
|
311
|
+
* network, and the layout work that hydration does. The page's own timers stay
|
|
312
|
+
* frozen throughout, so waiting longer here — on a slow host, a cold cache, a
|
|
313
|
+
* busy machine — never advances an animation. Bounded by `timeout` ms.
|
|
314
|
+
*/
|
|
315
|
+
async function settlePage(page, timeout, options = {}) {
|
|
316
|
+
const deadline = Date.now() + timeout;
|
|
317
|
+
const left = () => Math.max(0, deadline - Date.now());
|
|
318
|
+
// Fonts and images resolve off the loading pipeline, not off page timers.
|
|
319
|
+
await withDeadline(page.evaluate(async () => {
|
|
320
|
+
if (document.fonts?.ready)
|
|
321
|
+
await document.fonts.ready;
|
|
322
|
+
const pending = Array.from(document.images).filter(img => !img.complete);
|
|
323
|
+
if (pending.length)
|
|
324
|
+
await Promise.allSettled(pending.map(img => img.decode().catch(() => undefined)));
|
|
325
|
+
}), Math.min(3500, left()));
|
|
326
|
+
if (options.network !== false)
|
|
327
|
+
await waitForNetworkQuiet(page, 150, Math.min(left(), 2500));
|
|
328
|
+
await waitForStableLayout(page, left());
|
|
329
|
+
}
|
|
330
|
+
/** Resolve when `p` settles or `ms` elapses, whichever comes first. */
|
|
331
|
+
async function withDeadline(p, ms) {
|
|
332
|
+
let timer;
|
|
333
|
+
await Promise.race([
|
|
334
|
+
p.catch(() => undefined),
|
|
335
|
+
new Promise(resolve => { timer = setTimeout(resolve, ms); }),
|
|
336
|
+
]);
|
|
337
|
+
if (timer)
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
}
|
|
340
|
+
/** Cheap description of the page's structure; changes while it is still building itself. */
|
|
341
|
+
const LAYOUT_SIGNATURE = `(() => {
|
|
342
|
+
const de = document.documentElement;
|
|
343
|
+
return de.scrollHeight + ':' + de.scrollWidth + ':' + (document.body ? document.body.childElementCount : 0) + ':' + document.getElementsByTagName('*').length;
|
|
344
|
+
})()`;
|
|
345
|
+
/**
|
|
346
|
+
* Poll until the page stops restructuring itself. Polled from here rather than
|
|
347
|
+
* from a requestAnimationFrame loop in the page: page timers are frozen, so an
|
|
348
|
+
* in-page loop would never get a second frame.
|
|
349
|
+
*/
|
|
350
|
+
async function waitForStableLayout(page, timeout) {
|
|
351
|
+
const deadline = Date.now() + timeout;
|
|
352
|
+
let last = null;
|
|
353
|
+
let stable = 0;
|
|
354
|
+
while (stable < 2 && Date.now() < deadline) {
|
|
355
|
+
const signature = await page.evaluate(LAYOUT_SIGNATURE).catch(() => last);
|
|
356
|
+
if (signature !== null && signature === last)
|
|
357
|
+
stable++;
|
|
358
|
+
else {
|
|
359
|
+
stable = 0;
|
|
360
|
+
last = signature;
|
|
113
361
|
}
|
|
114
|
-
|
|
362
|
+
if (stable < 2)
|
|
363
|
+
await new Promise(resolve => setTimeout(resolve, STABILITY_POLL_MS));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Run the page's frozen timeline forward by `ms`, one frame at a time, so
|
|
368
|
+
* timer- and rAF-driven animation advances the same amount on every capture.
|
|
369
|
+
*
|
|
370
|
+
* A timer callback that throws is the page's own bug, not a reason to fail the
|
|
371
|
+
* screenshot: Playwright surfaces it here, so each frame is stepped separately
|
|
372
|
+
* and an error only costs that frame.
|
|
373
|
+
*/
|
|
374
|
+
async function advanceTimeline(page, ms) {
|
|
375
|
+
for (let elapsed = 0; elapsed < ms; elapsed += FRAME_MS) {
|
|
376
|
+
await page.clock.runFor(Math.min(FRAME_MS, ms - elapsed)).catch(() => undefined);
|
|
115
377
|
}
|
|
116
|
-
throw new Error('No usable Chromium found.\n' +
|
|
117
|
-
' 1. Set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome\n' +
|
|
118
|
-
' 2. Or run: npx playwright install chromium\n' +
|
|
119
|
-
' 3. For pre-captured images: pre-post before.png after.png --markdown');
|
|
120
378
|
}
|
|
121
379
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
380
|
+
* Resolve once no request has been in flight for `quietMs`, or after `cap` ms.
|
|
381
|
+
* Cheaper than Playwright's networkidle (which insists on a 500 ms window)
|
|
382
|
+
* and tolerant of dev servers that keep sockets open.
|
|
383
|
+
*/
|
|
384
|
+
function waitForNetworkQuiet(page, quietMs, cap) {
|
|
385
|
+
const tracker = inflight.get(page);
|
|
386
|
+
if (!tracker)
|
|
387
|
+
return Promise.resolve();
|
|
388
|
+
return new Promise(resolve => {
|
|
389
|
+
let timer = null;
|
|
390
|
+
const done = () => {
|
|
391
|
+
if (timer)
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
clearTimeout(hardStop);
|
|
394
|
+
tracker.listeners.delete(check);
|
|
395
|
+
resolve();
|
|
396
|
+
};
|
|
397
|
+
const check = () => {
|
|
398
|
+
if (timer)
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
timer = tracker.count === 0 ? setTimeout(done, quietMs) : null;
|
|
401
|
+
};
|
|
402
|
+
const hardStop = setTimeout(done, cap);
|
|
403
|
+
tracker.listeners.add(check);
|
|
404
|
+
check();
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const inflight = new WeakMap();
|
|
408
|
+
function trackRequests(page) {
|
|
409
|
+
const tracker = { count: 0, listeners: new Set() };
|
|
410
|
+
inflight.set(page, tracker);
|
|
411
|
+
const notify = () => { for (const l of tracker.listeners)
|
|
412
|
+
l(); };
|
|
413
|
+
page.on('request', req => {
|
|
414
|
+
// WebSocket upgrades and event streams never "finish"; ignore them.
|
|
415
|
+
if (req.resourceType() === 'websocket' || req.resourceType() === 'eventsource')
|
|
416
|
+
return;
|
|
417
|
+
tracker.count++;
|
|
418
|
+
notify();
|
|
419
|
+
});
|
|
420
|
+
const finish = (req) => {
|
|
421
|
+
if (req.resourceType() === 'websocket' || req.resourceType() === 'eventsource')
|
|
422
|
+
return;
|
|
423
|
+
tracker.count = Math.max(0, tracker.count - 1);
|
|
424
|
+
notify();
|
|
425
|
+
};
|
|
426
|
+
page.on('requestfinished', finish);
|
|
427
|
+
page.on('requestfailed', finish);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Scroll through the page once so lazy-loaded and reveal-on-scroll content is
|
|
431
|
+
* rendered before a full-page capture, then return to the top.
|
|
432
|
+
*
|
|
433
|
+
* Driven from here, a step at a time: the scrolling itself is real, but the
|
|
434
|
+
* frames that let observers and rAF callbacks run come out of the frozen
|
|
435
|
+
* timeline, so two identical pages take the same path down and back.
|
|
436
|
+
*/
|
|
437
|
+
async function primeLazyContent(page, maxHeight) {
|
|
438
|
+
const positions = await page.evaluate((limitPx) => {
|
|
439
|
+
const step = Math.max(200, window.innerHeight);
|
|
440
|
+
const limit = Math.min(document.documentElement.scrollHeight, limitPx);
|
|
441
|
+
const ys = [];
|
|
442
|
+
for (let y = step; y < limit + step; y += step)
|
|
443
|
+
ys.push(y);
|
|
444
|
+
return ys;
|
|
445
|
+
}, maxHeight);
|
|
446
|
+
for (const y of positions) {
|
|
447
|
+
await page.evaluate((to) => window.scrollTo(0, to), y);
|
|
448
|
+
await advanceTimeline(page, FRAME_MS * 2);
|
|
449
|
+
}
|
|
450
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
451
|
+
await advanceTimeline(page, FRAME_MS);
|
|
452
|
+
}
|
|
453
|
+
// ============================================================
|
|
454
|
+
// Capture
|
|
455
|
+
// ============================================================
|
|
456
|
+
/**
|
|
457
|
+
* Capture a screenshot. Safe to call concurrently — the page pool bounds parallelism.
|
|
458
|
+
* Throws HttpStatusError for 401/403 (auth required); other statuses are returned
|
|
459
|
+
* in the result so callers can decide (a 404 "before" for a new page is legitimate).
|
|
125
460
|
*/
|
|
126
461
|
export async function captureScreenshot(url, options) {
|
|
127
|
-
const
|
|
462
|
+
const started = Date.now();
|
|
463
|
+
const scale = options.scale ?? 2;
|
|
464
|
+
const settleTimeout = options.settleTimeout ?? 8000;
|
|
465
|
+
const maxHeight = options.maxHeight ?? options.viewport.height * 3;
|
|
466
|
+
await acquireSlot();
|
|
467
|
+
// Creating the context and the page sits inside the try, so a failure there
|
|
468
|
+
// still releases the slot and closes what was opened. Leaking a slot would
|
|
469
|
+
// eventually stall every later capture waiting for one.
|
|
470
|
+
let ctx;
|
|
471
|
+
let page;
|
|
128
472
|
try {
|
|
129
|
-
await
|
|
130
|
-
await
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// Disable animations and transitions for consistent captures
|
|
135
|
-
await pg.addStyleTag({
|
|
136
|
-
content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }',
|
|
473
|
+
ctx = await createContext(options.viewport, scale, options.auth);
|
|
474
|
+
page = await ctx.newPage();
|
|
475
|
+
trackRequests(page);
|
|
476
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(err => {
|
|
477
|
+
throw new NavigationError(classifyNavigationError(err), url, err);
|
|
137
478
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
479
|
+
const status = response?.status();
|
|
480
|
+
const vercel = response ? isVercelResponse({ get: n => response.headers()[n] ?? null }) : false;
|
|
481
|
+
if (status === 401 || status === 403)
|
|
482
|
+
throw new HttpStatusError(status, url, vercel);
|
|
483
|
+
await settlePage(page, settleTimeout);
|
|
484
|
+
if (options.fullPage) {
|
|
485
|
+
await primeLazyContent(page, maxHeight);
|
|
486
|
+
await settlePage(page, Math.min(settleTimeout, 2000));
|
|
487
|
+
}
|
|
488
|
+
// The page is loaded and quiet; now give its own timeline a fixed run so
|
|
489
|
+
// whatever it animates lands on the same frame here as on the other side.
|
|
490
|
+
await advanceTimeline(page, TIMELINE_BUDGET_MS);
|
|
491
|
+
// Timers that just fired may have asked for more content; let it arrive.
|
|
492
|
+
await settlePage(page, Math.min(settleTimeout, 2000));
|
|
493
|
+
// Only now look for the selector: an element mounted by a timer does not
|
|
494
|
+
// exist until that timer has fired, and with the clock held that is not
|
|
495
|
+
// until the budget above has run.
|
|
144
496
|
if (options.selector) {
|
|
145
|
-
const locator =
|
|
146
|
-
|
|
147
|
-
if (count === 0) {
|
|
497
|
+
const locator = page.locator(options.selector);
|
|
498
|
+
if ((await locator.count()) === 0)
|
|
148
499
|
throw new Error(`Element not found: ${options.selector}`);
|
|
149
|
-
}
|
|
150
500
|
await locator.first().scrollIntoViewIfNeeded();
|
|
501
|
+
await advanceTimeline(page, FRAME_MS * 2);
|
|
151
502
|
}
|
|
152
|
-
|
|
153
|
-
|
|
503
|
+
// `--wait` means "give this page longer": real time for anything still in
|
|
504
|
+
// flight, and the same again on the page's timeline for anything animating.
|
|
505
|
+
if (options.wait) {
|
|
506
|
+
await page.waitForTimeout(options.wait);
|
|
507
|
+
await advanceTimeline(page, options.wait);
|
|
508
|
+
}
|
|
509
|
+
let clip;
|
|
510
|
+
if (options.fullPage) {
|
|
511
|
+
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
512
|
+
if (height > maxHeight)
|
|
513
|
+
clip = { x: 0, y: 0, width: options.viewport.width, height: maxHeight };
|
|
514
|
+
}
|
|
515
|
+
// Where the browser actually ended up: a sign-in wall answers with 200
|
|
516
|
+
// after a redirect, so the status says nothing.
|
|
517
|
+
const finalUrl = page.url();
|
|
518
|
+
const image = await page.screenshot({
|
|
519
|
+
type: 'png',
|
|
520
|
+
fullPage: options.fullPage ?? false,
|
|
521
|
+
animations: 'disabled',
|
|
522
|
+
caret: 'hide',
|
|
523
|
+
clip,
|
|
524
|
+
timeout: 20_000,
|
|
525
|
+
});
|
|
526
|
+
// What the page calls itself, and what it says: the title recognises a
|
|
527
|
+
// sign-in wall that answered 200, the text tells a redesign of this site
|
|
528
|
+
// from a different site altogether. One round trip, and strictly after the
|
|
529
|
+
// screenshot — reading the page must not be able to influence the pixels
|
|
530
|
+
// the run is about to compare.
|
|
531
|
+
const { title, text } = await page
|
|
532
|
+
.evaluate(() => ({ title: document.title, text: document.body?.innerText ?? '' }))
|
|
533
|
+
.catch(() => ({ title: '', text: '' }));
|
|
534
|
+
return {
|
|
535
|
+
image,
|
|
536
|
+
viewport: options.viewport,
|
|
537
|
+
url,
|
|
538
|
+
status,
|
|
539
|
+
finalUrl,
|
|
540
|
+
title,
|
|
541
|
+
text,
|
|
542
|
+
vercel,
|
|
543
|
+
selector: options.selector,
|
|
544
|
+
durationMs: Date.now() - started,
|
|
545
|
+
};
|
|
154
546
|
}
|
|
155
547
|
finally {
|
|
156
|
-
await
|
|
548
|
+
await page?.close().catch(() => undefined);
|
|
549
|
+
await ctx?.close().catch(() => undefined);
|
|
550
|
+
releaseSlot();
|
|
157
551
|
}
|
|
158
552
|
}
|
|
553
|
+
function classifyNavigationError(err) {
|
|
554
|
+
const msg = err.message || '';
|
|
555
|
+
if (err.name === 'TimeoutError' || /Timeout .* exceeded/.test(msg))
|
|
556
|
+
return 'timeout';
|
|
557
|
+
if (/ERR_CONNECTION_REFUSED/.test(msg))
|
|
558
|
+
return 'refused';
|
|
559
|
+
if (/ERR_NAME_NOT_RESOLVED|ENOTFOUND/.test(msg))
|
|
560
|
+
return 'dns';
|
|
561
|
+
return 'other';
|
|
562
|
+
}
|
|
159
563
|
/**
|
|
160
564
|
* Close the browser session and clean up resources.
|
|
161
565
|
*/
|
|
162
566
|
export async function closeBrowser() {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
567
|
+
const b = browser;
|
|
568
|
+
browser = null;
|
|
569
|
+
launching = null;
|
|
167
570
|
activePages = 0;
|
|
168
571
|
pageQueue.length = 0;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
* Read a pre-captured screenshot from disk.
|
|
172
|
-
* Used in MCP mode where Playwright MCP saves files directly.
|
|
173
|
-
*/
|
|
174
|
-
export function readScreenshot(filepath) {
|
|
175
|
-
if (!fs.existsSync(filepath)) {
|
|
176
|
-
throw new Error(`Screenshot not found: ${filepath}`);
|
|
177
|
-
}
|
|
178
|
-
return fs.readFileSync(filepath);
|
|
572
|
+
if (b)
|
|
573
|
+
await b.close().catch(() => undefined);
|
|
179
574
|
}
|
|
180
575
|
//# sourceMappingURL=browser.js.map
|