@reshotdev/screenshot 0.0.1-beta.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/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- package/web/subtitle-editor/index.html +295 -0
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
// capture-engine.js - Robust capture engine for SaaS documentation screenshots
|
|
2
|
+
// Designed for stability over flexibility - every action waits and verifies
|
|
3
|
+
|
|
4
|
+
const { chromium } = require("playwright");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const fs = require("fs-extra");
|
|
7
|
+
const chalk = require("chalk");
|
|
8
|
+
const { buildLaunchOptions } = require("./ci-detect");
|
|
9
|
+
const {
|
|
10
|
+
applyVariantToPage,
|
|
11
|
+
applyStorageAndReload,
|
|
12
|
+
setupHeaderInterception,
|
|
13
|
+
getBrowserOptions,
|
|
14
|
+
logVariantSummary,
|
|
15
|
+
} = require("./variant-injector");
|
|
16
|
+
const {
|
|
17
|
+
cropImageBuffer,
|
|
18
|
+
mergeCropConfigs,
|
|
19
|
+
validateCropConfig,
|
|
20
|
+
scaleRegionByDPR,
|
|
21
|
+
isSharpAvailable,
|
|
22
|
+
} = require("./image-crop");
|
|
23
|
+
const { sanitizeStorageState } = require("./record-cdp");
|
|
24
|
+
const {
|
|
25
|
+
injectPrivacyMasking,
|
|
26
|
+
removePrivacyMasking,
|
|
27
|
+
} = require("./privacy-engine");
|
|
28
|
+
const { applyStyle, isStyleAvailable } = require("./style-engine");
|
|
29
|
+
|
|
30
|
+
// Default path patterns that indicate an auth redirect
|
|
31
|
+
const DEFAULT_AUTH_PATH_PATTERNS = [
|
|
32
|
+
"/auth/signin",
|
|
33
|
+
"/auth/login",
|
|
34
|
+
"/auth/confirm",
|
|
35
|
+
"/login",
|
|
36
|
+
"/signin",
|
|
37
|
+
"/sign-in",
|
|
38
|
+
"/log-in",
|
|
39
|
+
"/sso/",
|
|
40
|
+
"/oauth/",
|
|
41
|
+
"/saml/",
|
|
42
|
+
"/cas/",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// Known OAuth provider domains — if the page lands here, auth is required
|
|
46
|
+
const OAUTH_PROVIDER_DOMAINS = [
|
|
47
|
+
"accounts.google.com",
|
|
48
|
+
"login.microsoftonline.com",
|
|
49
|
+
"auth0.com",
|
|
50
|
+
"okta.com",
|
|
51
|
+
"login.salesforce.com",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check whether a URL indicates an authentication redirect.
|
|
56
|
+
* Matches against default path patterns, known OAuth provider domains,
|
|
57
|
+
* and optional user-supplied custom patterns.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} url - The URL to check
|
|
60
|
+
* @param {string[]} customPatterns - Additional path substrings to match
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function isAuthRedirectUrl(url, customPatterns = []) {
|
|
64
|
+
if (!url) return false;
|
|
65
|
+
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = new URL(url);
|
|
69
|
+
} catch {
|
|
70
|
+
// If the URL can't be parsed, fall back to substring matching
|
|
71
|
+
const allPatterns = [...DEFAULT_AUTH_PATH_PATTERNS, ...customPatterns];
|
|
72
|
+
return allPatterns.some((p) => url.includes(p));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check OAuth provider domains
|
|
76
|
+
const hostname = parsed.hostname;
|
|
77
|
+
if (OAUTH_PROVIDER_DOMAINS.some((d) => hostname.includes(d))) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check path patterns (default + custom)
|
|
82
|
+
const pathname = parsed.pathname;
|
|
83
|
+
const allPatterns = [...DEFAULT_AUTH_PATH_PATTERNS, ...customPatterns];
|
|
84
|
+
return allPatterns.some((p) => pathname.includes(p));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Browser automation engine with semantic selectors and variant injection.
|
|
89
|
+
*/
|
|
90
|
+
class CaptureEngine {
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
this.browser = null;
|
|
93
|
+
this.page = null;
|
|
94
|
+
this.context = null;
|
|
95
|
+
this.outputDir = options.outputDir || ".reshot/output";
|
|
96
|
+
this.viewport = options.viewport || { width: 1280, height: 720 };
|
|
97
|
+
this.baseUrl = options.baseUrl || "";
|
|
98
|
+
this.capturedAssets = [];
|
|
99
|
+
this.logger = options.logger || console.log;
|
|
100
|
+
this.headless = options.headless !== false; // Default to headless
|
|
101
|
+
|
|
102
|
+
// Storage state path for authenticated sessions
|
|
103
|
+
// If provided, loads cookies/localStorage from file to preserve auth state
|
|
104
|
+
this.storageStatePath = options.storageStatePath || null;
|
|
105
|
+
|
|
106
|
+
// Hide development UI overlays (Next.js devtools, etc.)
|
|
107
|
+
this.hideDevtools = options.hideDevtools !== false; // Default to true
|
|
108
|
+
|
|
109
|
+
// Universal variant configuration
|
|
110
|
+
// Supports multiple injection methods: localStorage, sessionStorage, cookie,
|
|
111
|
+
// urlParam, browser (locale/timezone), script, header
|
|
112
|
+
this.variantConfig = options.variantConfig || null;
|
|
113
|
+
|
|
114
|
+
// Crop configuration - applied to all captures unless overridden per-step
|
|
115
|
+
// Persisted across variations for consistent output
|
|
116
|
+
this.cropConfig = options.cropConfig || null;
|
|
117
|
+
|
|
118
|
+
// Validate crop config at initialization
|
|
119
|
+
if (this.cropConfig) {
|
|
120
|
+
const validation = validateCropConfig(this.cropConfig);
|
|
121
|
+
if (!validation.valid) {
|
|
122
|
+
this.logger(
|
|
123
|
+
chalk.yellow(` ⚠ Invalid crop config: ${validation.error}`)
|
|
124
|
+
);
|
|
125
|
+
this.cropConfig = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Pre-loaded storage state object (avoids redundant file reads in parallel mode)
|
|
130
|
+
this.storageStateData = options.storageStateData || null;
|
|
131
|
+
|
|
132
|
+
// Custom auth redirect patterns (appended to defaults)
|
|
133
|
+
this._customAuthPatterns = options.authPatterns || [];
|
|
134
|
+
|
|
135
|
+
// Custom loading-state hook — lets users specify app-specific readiness signals
|
|
136
|
+
// { selector?: string, expression?: string, timeout?: number }
|
|
137
|
+
this.waitForReady = options.waitForReady || null;
|
|
138
|
+
|
|
139
|
+
// Privacy masking configuration (CSS injection for PII redaction)
|
|
140
|
+
this.privacyConfig = options.privacyConfig || null;
|
|
141
|
+
|
|
142
|
+
// Style configuration (image beautification post-capture)
|
|
143
|
+
this.styleConfig = options.styleConfig || null;
|
|
144
|
+
|
|
145
|
+
// Legacy support for old variant format
|
|
146
|
+
if (!this.variantConfig && options.variant) {
|
|
147
|
+
this.variantConfig = this._convertLegacyVariant(options.variant);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convert legacy variant format to new universal format
|
|
153
|
+
*/
|
|
154
|
+
_convertLegacyVariant(variant) {
|
|
155
|
+
const injections = [];
|
|
156
|
+
const browserOptions = {};
|
|
157
|
+
const summary = [];
|
|
158
|
+
|
|
159
|
+
// Convert locale config
|
|
160
|
+
if (variant.locale) {
|
|
161
|
+
if (variant.locale.browserLocale) {
|
|
162
|
+
browserOptions.locale = variant.locale.browserLocale;
|
|
163
|
+
}
|
|
164
|
+
if (variant.locale.timezone) {
|
|
165
|
+
browserOptions.timezoneId = variant.locale.timezone;
|
|
166
|
+
}
|
|
167
|
+
if (variant.locale.storage) {
|
|
168
|
+
for (const [key, value] of Object.entries(variant.locale.storage)) {
|
|
169
|
+
injections.push({ method: "localStorage", key, value });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
summary.push(
|
|
173
|
+
`Locale: ${variant.locale.name || variant.locale.key || "custom"}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Convert role config
|
|
178
|
+
if (variant.role) {
|
|
179
|
+
if (variant.role.storage) {
|
|
180
|
+
for (const [key, value] of Object.entries(variant.role.storage)) {
|
|
181
|
+
injections.push({ method: "localStorage", key, value });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
summary.push(
|
|
185
|
+
`Role: ${variant.role.name || variant.role.key || "custom"}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { injections, browserOptions, summary, metadata: {} };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Initialize the browser with variant support
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* Build context options from variant config and storage state.
|
|
197
|
+
* Extracted so it can be reused with browser pool contexts.
|
|
198
|
+
*/
|
|
199
|
+
_buildContextOptions() {
|
|
200
|
+
const defaultOptions = {
|
|
201
|
+
viewport: this.viewport,
|
|
202
|
+
deviceScaleFactor: 2, // Retina quality screenshots
|
|
203
|
+
locale: "en-US",
|
|
204
|
+
timezoneId: "America/New_York",
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const contextOptions = getBrowserOptions(
|
|
208
|
+
this.variantConfig,
|
|
209
|
+
defaultOptions
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Suppress permission dialogs (notifications, geolocation, etc.)
|
|
213
|
+
contextOptions.permissions = [];
|
|
214
|
+
|
|
215
|
+
if (contextOptions.colorScheme) {
|
|
216
|
+
this.logger(chalk.gray(` → colorScheme: ${contextOptions.colorScheme}`));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Load storage state: prefer pre-loaded data, fall back to file path
|
|
220
|
+
// Always sanitize to prevent "Invalid cookie fields" from CDP-sourced cookies
|
|
221
|
+
if (this.storageStateData) {
|
|
222
|
+
const { sanitized, stats } = sanitizeStorageState(this.storageStateData);
|
|
223
|
+
contextOptions.storageState = sanitized;
|
|
224
|
+
if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
|
|
225
|
+
this.logger(chalk.gray(` → Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
|
|
226
|
+
}
|
|
227
|
+
this.logger(chalk.gray(` → Using pre-loaded auth session`));
|
|
228
|
+
} else if (this.storageStatePath && fs.existsSync(this.storageStatePath)) {
|
|
229
|
+
// Read and sanitize instead of passing raw file path (Playwright would read it unsanitized)
|
|
230
|
+
try {
|
|
231
|
+
const rawState = JSON.parse(fs.readFileSync(this.storageStatePath, "utf-8"));
|
|
232
|
+
const { sanitized, stats } = sanitizeStorageState(rawState);
|
|
233
|
+
contextOptions.storageState = sanitized;
|
|
234
|
+
if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
|
|
235
|
+
this.logger(chalk.gray(` → Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
|
|
236
|
+
}
|
|
237
|
+
} catch (_e) {
|
|
238
|
+
// Fall back to raw file path if JSON parse fails
|
|
239
|
+
contextOptions.storageState = this.storageStatePath;
|
|
240
|
+
}
|
|
241
|
+
this.logger(
|
|
242
|
+
chalk.gray(
|
|
243
|
+
` → Loading auth session from: ${path.basename(
|
|
244
|
+
this.storageStatePath
|
|
245
|
+
)}`
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return contextOptions;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async init() {
|
|
254
|
+
this.logger(chalk.cyan("🚀 Initializing capture engine..."));
|
|
255
|
+
|
|
256
|
+
const contextOptions = this._buildContextOptions();
|
|
257
|
+
|
|
258
|
+
this.browser = await chromium.launch(buildLaunchOptions({
|
|
259
|
+
headless: this.headless,
|
|
260
|
+
}));
|
|
261
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
262
|
+
this.page = await this.context.newPage();
|
|
263
|
+
|
|
264
|
+
// Hide development UI overlays (Next.js devtools, React DevTools, etc.)
|
|
265
|
+
if (this.hideDevtools) {
|
|
266
|
+
await this._setupDevtoolsHiding();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Apply all variant injections (localStorage, sessionStorage, cookies, scripts)
|
|
270
|
+
if (this.variantConfig) {
|
|
271
|
+
await applyVariantToPage(this.page, this.variantConfig, this.logger);
|
|
272
|
+
|
|
273
|
+
// Set up header interception if needed
|
|
274
|
+
if (
|
|
275
|
+
this.variantConfig.headers &&
|
|
276
|
+
Object.keys(this.variantConfig.headers).length > 0
|
|
277
|
+
) {
|
|
278
|
+
await setupHeaderInterception(this.page, this.variantConfig.headers);
|
|
279
|
+
this.logger(
|
|
280
|
+
chalk.gray(
|
|
281
|
+
` → HTTP headers: ${Object.keys(this.variantConfig.headers).join(
|
|
282
|
+
", "
|
|
283
|
+
)}`
|
|
284
|
+
)
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Log variant summary
|
|
289
|
+
logVariantSummary(this.variantConfig, this.logger);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await this._injectWorkspaceStore();
|
|
293
|
+
|
|
294
|
+
// Inject privacy masking CSS (after variant injection, before captures)
|
|
295
|
+
this._privacyInjectionOk = true;
|
|
296
|
+
if (this.privacyConfig && this.privacyConfig.enabled && this.privacyConfig.selectors?.length > 0) {
|
|
297
|
+
const privacyResult = await injectPrivacyMasking(this.page, this.privacyConfig, this.logger);
|
|
298
|
+
this._privacyInjectionOk = privacyResult.success;
|
|
299
|
+
if (!privacyResult.success) {
|
|
300
|
+
// Non-suppressible — always warn even in quiet mode
|
|
301
|
+
console.error(chalk.red(` ✖ PRIVACY: Injection failed — captures will be skipped to prevent PII leak. Error: ${privacyResult.error}`));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Track HTTP 401/403 on document requests — indicates auth is required
|
|
306
|
+
this._authResponseDetected = false;
|
|
307
|
+
this.page.on("response", (response) => {
|
|
308
|
+
const status = response.status();
|
|
309
|
+
if (
|
|
310
|
+
(status === 401 || status === 403) &&
|
|
311
|
+
response.request().resourceType() === "document"
|
|
312
|
+
) {
|
|
313
|
+
this._authResponseDetected = true;
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Set up error handling
|
|
318
|
+
this.page.on("pageerror", (err) => {
|
|
319
|
+
const firstLine = (err.message || '').split('\n')[0].slice(0, 200);
|
|
320
|
+
this.logger(chalk.yellow(` [Page Error] ${firstLine}`));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
this.logger(chalk.green(" ✔ Browser initialized"));
|
|
324
|
+
return this;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
|
|
329
|
+
* Injects CSS to hide common development UI elements before each navigation
|
|
330
|
+
*/
|
|
331
|
+
async _setupDevtoolsHiding() {
|
|
332
|
+
// Add CSS to hide common development overlays
|
|
333
|
+
const hideDevtoolsCSS = `
|
|
334
|
+
/* Next.js Development Overlay */
|
|
335
|
+
[data-nextjs-dialog],
|
|
336
|
+
[data-nextjs-dialog-overlay],
|
|
337
|
+
[data-nextjs-toast],
|
|
338
|
+
#__next-build-watcher,
|
|
339
|
+
nextjs-portal,
|
|
340
|
+
|
|
341
|
+
/* Vercel Toolbar */
|
|
342
|
+
[data-vercel-toolbar],
|
|
343
|
+
#vercel-live-feedback,
|
|
344
|
+
|
|
345
|
+
/* React DevTools */
|
|
346
|
+
#__REACT_DEVTOOLS_GLOBAL_HOOK__,
|
|
347
|
+
|
|
348
|
+
/* Common hot reload indicators */
|
|
349
|
+
[data-hot-reload],
|
|
350
|
+
.webpack-hot-middleware-clientOverlay,
|
|
351
|
+
|
|
352
|
+
/* ChannelIO chat widget */
|
|
353
|
+
#ch-plugin,
|
|
354
|
+
#ch-plugin-core,
|
|
355
|
+
.ch-desk-messenger,
|
|
356
|
+
[class*="ChannelIO"],
|
|
357
|
+
[id*="channel-io"],
|
|
358
|
+
#channel-io-plugin,
|
|
359
|
+
|
|
360
|
+
/* Cookie consent banners */
|
|
361
|
+
.cookie-consent,
|
|
362
|
+
#cookie-banner,
|
|
363
|
+
[data-testid="cookie-banner"],
|
|
364
|
+
.cc-banner,
|
|
365
|
+
#onetrust-banner-sdk,
|
|
366
|
+
.CookieConsent,
|
|
367
|
+
#gdpr-cookie-notice,
|
|
368
|
+
.cookie-notice,
|
|
369
|
+
[class*="cookie-consent"],
|
|
370
|
+
[class*="CookieConsent"],
|
|
371
|
+
|
|
372
|
+
/* Other third-party widgets */
|
|
373
|
+
.intercom-lightweight-app,
|
|
374
|
+
#hubspot-messages-iframe-container {
|
|
375
|
+
display: none !important;
|
|
376
|
+
visibility: hidden !important;
|
|
377
|
+
opacity: 0 !important;
|
|
378
|
+
pointer-events: none !important;
|
|
379
|
+
}
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
// Inject CSS on every frame and navigation
|
|
383
|
+
await this.page.addStyleTag({ content: hideDevtoolsCSS });
|
|
384
|
+
|
|
385
|
+
// Also inject on route changes for SPAs
|
|
386
|
+
this.page.on("framenavigated", async (frame) => {
|
|
387
|
+
if (frame === this.page.mainFrame()) {
|
|
388
|
+
try {
|
|
389
|
+
await this.page.addStyleTag({ content: hideDevtoolsCSS });
|
|
390
|
+
} catch (e) {
|
|
391
|
+
// Page might have closed, ignore
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
this.logger(chalk.gray(" → Dev overlays will be hidden"));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Inject workspace store data (activeProjectId + activeWorkspace) into app's localStorage.
|
|
401
|
+
* Without both fields, the target app's Zustand workspace store starts empty
|
|
402
|
+
* and pages show "Failed to load project".
|
|
403
|
+
*/
|
|
404
|
+
async _injectWorkspaceStore() {
|
|
405
|
+
// Try to get PROJECT_ID and workspace from multiple sources (fallback chain)
|
|
406
|
+
let projectId = null;
|
|
407
|
+
let workspace = null;
|
|
408
|
+
try {
|
|
409
|
+
const config = require("./config");
|
|
410
|
+
const settings = config.readSettings() || {};
|
|
411
|
+
// 1. Check settings urlVariables
|
|
412
|
+
projectId = settings.urlVariables?.PROJECT_ID;
|
|
413
|
+
// 2. Check settings projectId
|
|
414
|
+
if (!projectId) projectId = settings.projectId;
|
|
415
|
+
// 3. Check docsync.config.json urlVariables
|
|
416
|
+
if (!projectId) {
|
|
417
|
+
try {
|
|
418
|
+
const docsyncConfig = config.readConfig() || {};
|
|
419
|
+
projectId = docsyncConfig.urlVariables?.PROJECT_ID;
|
|
420
|
+
} catch (_e) {
|
|
421
|
+
// Config may not exist
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// 4. Check environment variable
|
|
425
|
+
if (!projectId) projectId = process.env.RESHOT_PROJECT_ID;
|
|
426
|
+
// Get workspace data from settings
|
|
427
|
+
workspace = settings.workspace || null;
|
|
428
|
+
} catch (e) {
|
|
429
|
+
// Settings not available, skip injection
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!projectId) {
|
|
434
|
+
this.logger(
|
|
435
|
+
chalk.yellow(" ⚠ No PROJECT_ID configured. Platform pages may show 'No project selected'.\n Set urlVariables.PROJECT_ID in .reshot/settings.json or RESHOT_PROJECT_ID env var.")
|
|
436
|
+
);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Store for post-navigation re-injection
|
|
441
|
+
this._activeProjectId = projectId;
|
|
442
|
+
this._activeWorkspace = workspace;
|
|
443
|
+
|
|
444
|
+
// Inject via addInitScript so it runs before any page JS
|
|
445
|
+
await this.page.addInitScript(({ pid, ws }) => {
|
|
446
|
+
const storeState = {
|
|
447
|
+
activeProjectId: pid,
|
|
448
|
+
sidebarMinimized: true,
|
|
449
|
+
};
|
|
450
|
+
if (ws) {
|
|
451
|
+
storeState.activeWorkspace = { id: ws.id, name: ws.name, slug: ws.slug };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Update existing workspace store entries (support both legacy and current key prefixes)
|
|
455
|
+
const storePrefixes = ["reshot-store-", "workspace-store-"];
|
|
456
|
+
let found = false;
|
|
457
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
458
|
+
const key = localStorage.key(i);
|
|
459
|
+
if (key && storePrefixes.some(p => key.startsWith(p))) {
|
|
460
|
+
try {
|
|
461
|
+
const data = JSON.parse(localStorage.getItem(key) || "{}");
|
|
462
|
+
data.state = { ...data.state, ...storeState };
|
|
463
|
+
data.version = data.version ?? 0;
|
|
464
|
+
localStorage.setItem(key, JSON.stringify(data));
|
|
465
|
+
found = true;
|
|
466
|
+
} catch (e) {
|
|
467
|
+
// Invalid JSON, skip
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// Create default store if none existed (use current Zustand persist key)
|
|
472
|
+
if (!found) {
|
|
473
|
+
localStorage.setItem(
|
|
474
|
+
"reshot-store-workspace",
|
|
475
|
+
JSON.stringify({ state: storeState, version: 0 })
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}, { pid: projectId, ws: workspace });
|
|
479
|
+
|
|
480
|
+
this.logger(
|
|
481
|
+
chalk.gray(` → Injected workspace store: projectId=${projectId.slice(0, 12)}...${workspace ? `, workspace=${workspace.slug}` : ""}`)
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Navigate to a URL and wait for it to be fully loaded
|
|
487
|
+
*/
|
|
488
|
+
async goto(url, options = {}) {
|
|
489
|
+
const fullUrl = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
|
|
490
|
+
this.logger(chalk.cyan(`📍 Navigating to ${fullUrl}`));
|
|
491
|
+
|
|
492
|
+
await this.page.goto(fullUrl, {
|
|
493
|
+
waitUntil: "domcontentloaded",
|
|
494
|
+
timeout: 30000,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// CRITICAL FIX: For SSR apps with inline <script> tags that read localStorage
|
|
498
|
+
// during HTML parsing, we must use page.evaluate() to set localStorage
|
|
499
|
+
// AFTER navigation, then reload so the inline scripts can read the values.
|
|
500
|
+
// This is because addInitScript runs when JS context initializes, which is
|
|
501
|
+
// AFTER inline <script> tags have already executed.
|
|
502
|
+
if (this.variantConfig && !this._hasAppliedStorageReload) {
|
|
503
|
+
this._hasAppliedStorageReload = true; // Only do this once per session
|
|
504
|
+
const didReload = await applyStorageAndReload(
|
|
505
|
+
this.page,
|
|
506
|
+
this.variantConfig,
|
|
507
|
+
this.logger
|
|
508
|
+
);
|
|
509
|
+
if (didReload) {
|
|
510
|
+
this.logger(chalk.gray(" → Page reloaded with localStorage applied"));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Check for auth redirect after navigation (URL patterns + HTTP 401/403)
|
|
515
|
+
const currentUrl = this.page.url();
|
|
516
|
+
const isAuthRedirect =
|
|
517
|
+
isAuthRedirectUrl(currentUrl, this._customAuthPatterns) ||
|
|
518
|
+
this._authResponseDetected;
|
|
519
|
+
if (isAuthRedirect) {
|
|
520
|
+
const errorMsg = `Auth redirect detected: navigated to ${currentUrl}. Session may be expired. Re-run \`reshot record\` to refresh session, or export a fresh Playwright storage state to .reshot/auth-state.json.`;
|
|
521
|
+
this.logger(chalk.red(` ✖ ${errorMsg}`));
|
|
522
|
+
throw new Error(errorMsg);
|
|
523
|
+
}
|
|
524
|
+
// Reset flag after check so subsequent navigations get a clean slate
|
|
525
|
+
this._authResponseDetected = false;
|
|
526
|
+
|
|
527
|
+
// Wait for network to settle
|
|
528
|
+
await this._waitForStability();
|
|
529
|
+
|
|
530
|
+
// Additional wait for theme/variants to fully apply
|
|
531
|
+
// This handles CSS transitions and async re-renders
|
|
532
|
+
if (this.variantConfig && this.variantConfig.injections?.length > 0) {
|
|
533
|
+
this.logger(chalk.gray(" → Waiting for variant styles to apply..."));
|
|
534
|
+
await this.page.waitForTimeout(500);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Re-inject workspace store after navigation to handle Zustand hydration resets
|
|
538
|
+
if (this._activeProjectId) {
|
|
539
|
+
await this.page.evaluate(({ pid, ws }) => {
|
|
540
|
+
const storePrefixes = ["reshot-store-", "workspace-store-"];
|
|
541
|
+
let foundKey = null;
|
|
542
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
543
|
+
const key = localStorage.key(i);
|
|
544
|
+
if (key && storePrefixes.some(p => key.startsWith(p))) {
|
|
545
|
+
try {
|
|
546
|
+
const data = JSON.parse(localStorage.getItem(key) || "{}");
|
|
547
|
+
if (data.state) {
|
|
548
|
+
data.state.activeProjectId = pid;
|
|
549
|
+
if (ws) data.state.activeWorkspace = data.state.activeWorkspace || { id: ws.id, name: ws.name, slug: ws.slug };
|
|
550
|
+
data.version = data.version ?? 0;
|
|
551
|
+
localStorage.setItem(key, JSON.stringify(data));
|
|
552
|
+
foundKey = key;
|
|
553
|
+
}
|
|
554
|
+
} catch (e) {}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// Trigger Zustand storage listener to rehydrate with the correct key
|
|
558
|
+
window.dispatchEvent(new StorageEvent("storage", { key: foundKey || "reshot-store-workspace" }));
|
|
559
|
+
}, { pid: this._activeProjectId, ws: this._activeWorkspace });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this.logger(chalk.green(" ✔ Page loaded"));
|
|
563
|
+
return this;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Find an element using multiple strategies
|
|
568
|
+
* This is the core of robust element selection
|
|
569
|
+
*
|
|
570
|
+
* Following Playwright/Testing Library best practices:
|
|
571
|
+
* 1. Role-based locators (most semantic, resilient)
|
|
572
|
+
* 2. Label-based locators (for form controls)
|
|
573
|
+
* 3. Placeholder/text locators
|
|
574
|
+
* 4. Test ID locators (explicit contract)
|
|
575
|
+
* 5. CSS selectors (fallback)
|
|
576
|
+
*/
|
|
577
|
+
async _findElement(target, options = {}) {
|
|
578
|
+
const { timeout = 10000, mustBeVisible = true } = options;
|
|
579
|
+
|
|
580
|
+
// If target is already a locator, use it
|
|
581
|
+
if (typeof target !== "string") {
|
|
582
|
+
return target;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Handle role-based selectors (from new selector strategies)
|
|
586
|
+
// Format: role=button[name="Submit"]
|
|
587
|
+
if (target.startsWith("role=")) {
|
|
588
|
+
const roleMatch = target.match(/^role=(\w+)(?:\[name="(.+)"\])?$/);
|
|
589
|
+
if (roleMatch) {
|
|
590
|
+
const [, role, name] = roleMatch;
|
|
591
|
+
const locator = name
|
|
592
|
+
? this.page.getByRole(role, { name })
|
|
593
|
+
: this.page.getByRole(role);
|
|
594
|
+
try {
|
|
595
|
+
await locator.first().waitFor({
|
|
596
|
+
state: mustBeVisible ? "visible" : "attached",
|
|
597
|
+
timeout,
|
|
598
|
+
});
|
|
599
|
+
return locator.first();
|
|
600
|
+
} catch (e) {
|
|
601
|
+
// Continue to other strategies
|
|
602
|
+
this.logger(chalk.gray(` Role selector failed: ${target}`));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Handle label-based selectors
|
|
608
|
+
// Format: label:has-text("Email") >> input
|
|
609
|
+
if (target.includes(" >> ") && target.includes("label:has-text")) {
|
|
610
|
+
try {
|
|
611
|
+
const locator = this.page.locator(target).first();
|
|
612
|
+
await locator.waitFor({
|
|
613
|
+
state: mustBeVisible ? "visible" : "attached",
|
|
614
|
+
timeout: 3000,
|
|
615
|
+
});
|
|
616
|
+
return locator;
|
|
617
|
+
} catch (e) {
|
|
618
|
+
// Continue to other strategies
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Strategy 1: If it looks like a CSS selector, try it directly
|
|
623
|
+
if (
|
|
624
|
+
target.startsWith("#") ||
|
|
625
|
+
target.startsWith(".") ||
|
|
626
|
+
target.startsWith("[")
|
|
627
|
+
) {
|
|
628
|
+
const locator = this.page.locator(target).first();
|
|
629
|
+
try {
|
|
630
|
+
await locator.waitFor({
|
|
631
|
+
state: mustBeVisible ? "visible" : "attached",
|
|
632
|
+
timeout,
|
|
633
|
+
});
|
|
634
|
+
return locator;
|
|
635
|
+
} catch (e) {
|
|
636
|
+
// Continue to other strategies
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Strategy 2: Playwright's semantic locators (best practice)
|
|
641
|
+
const semanticStrategies = [
|
|
642
|
+
// Role-based (most reliable for buttons, links, etc.)
|
|
643
|
+
() => this.page.getByRole("button", { name: target }),
|
|
644
|
+
() => this.page.getByRole("link", { name: target }),
|
|
645
|
+
() => this.page.getByRole("menuitem", { name: target }),
|
|
646
|
+
() => this.page.getByRole("tab", { name: target }),
|
|
647
|
+
() => this.page.getByRole("checkbox", { name: target }),
|
|
648
|
+
() => this.page.getByRole("radio", { name: target }),
|
|
649
|
+
() => this.page.getByRole("textbox", { name: target }),
|
|
650
|
+
() => this.page.getByRole("combobox", { name: target }),
|
|
651
|
+
// Label-based (for form controls)
|
|
652
|
+
() => this.page.getByLabel(target),
|
|
653
|
+
// Placeholder-based (for inputs)
|
|
654
|
+
() => this.page.getByPlaceholder(target),
|
|
655
|
+
// Text-based (for general content)
|
|
656
|
+
() => this.page.getByText(target, { exact: true }),
|
|
657
|
+
() => this.page.getByText(target),
|
|
658
|
+
// Test ID (explicit contract)
|
|
659
|
+
() => this.page.getByTestId(target),
|
|
660
|
+
// Alt text (for images)
|
|
661
|
+
() => this.page.getByAltText(target),
|
|
662
|
+
// Title (for elements with title attribute)
|
|
663
|
+
() => this.page.getByTitle(target),
|
|
664
|
+
];
|
|
665
|
+
|
|
666
|
+
for (const getLocator of semanticStrategies) {
|
|
667
|
+
try {
|
|
668
|
+
const locator = getLocator().first();
|
|
669
|
+
await locator.waitFor({
|
|
670
|
+
state: mustBeVisible ? "visible" : "attached",
|
|
671
|
+
timeout: 2000,
|
|
672
|
+
});
|
|
673
|
+
return locator;
|
|
674
|
+
} catch (e) {
|
|
675
|
+
// Try next strategy
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Strategy 3: Text-based CSS selectors (legacy fallback)
|
|
680
|
+
const textStrategies = [
|
|
681
|
+
// Button with text
|
|
682
|
+
`button:has-text("${target}")`,
|
|
683
|
+
// Link with text
|
|
684
|
+
`a:has-text("${target}")`,
|
|
685
|
+
// Any element with text
|
|
686
|
+
`*:has-text("${target}")`,
|
|
687
|
+
];
|
|
688
|
+
|
|
689
|
+
for (const strategy of textStrategies) {
|
|
690
|
+
try {
|
|
691
|
+
const locator = this.page.locator(strategy).first();
|
|
692
|
+
await locator.waitFor({
|
|
693
|
+
state: mustBeVisible ? "visible" : "attached",
|
|
694
|
+
timeout: 2000,
|
|
695
|
+
});
|
|
696
|
+
return locator;
|
|
697
|
+
} catch (e) {
|
|
698
|
+
// Try next strategy
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Strategy 4: Placeholder/label for inputs (legacy)
|
|
703
|
+
const inputStrategies = [
|
|
704
|
+
`input[placeholder*="${target}" i]`,
|
|
705
|
+
`textarea[placeholder*="${target}" i]`,
|
|
706
|
+
`input[name*="${target}" i]`,
|
|
707
|
+
`label:has-text("${target}") + input`,
|
|
708
|
+
`label:has-text("${target}") input`,
|
|
709
|
+
];
|
|
710
|
+
|
|
711
|
+
for (const strategy of inputStrategies) {
|
|
712
|
+
try {
|
|
713
|
+
const locator = this.page.locator(strategy).first();
|
|
714
|
+
await locator.waitFor({
|
|
715
|
+
state: mustBeVisible ? "visible" : "attached",
|
|
716
|
+
timeout: 1500,
|
|
717
|
+
});
|
|
718
|
+
return locator;
|
|
719
|
+
} catch (e) {
|
|
720
|
+
// Try next strategy
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Strategy 5: Aria and test attributes (legacy)
|
|
725
|
+
const ariaStrategies = [
|
|
726
|
+
`[aria-label*="${target}" i]`,
|
|
727
|
+
`[data-testid*="${target}" i]`,
|
|
728
|
+
`[data-test*="${target}" i]`,
|
|
729
|
+
`[data-cy*="${target}" i]`,
|
|
730
|
+
];
|
|
731
|
+
|
|
732
|
+
for (const strategy of ariaStrategies) {
|
|
733
|
+
try {
|
|
734
|
+
const locator = this.page.locator(strategy).first();
|
|
735
|
+
await locator.waitFor({
|
|
736
|
+
state: mustBeVisible ? "visible" : "attached",
|
|
737
|
+
timeout: 1500,
|
|
738
|
+
});
|
|
739
|
+
return locator;
|
|
740
|
+
} catch (e) {
|
|
741
|
+
// Try next strategy
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
throw new Error(`Could not find element: "${target}"`);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Click on an element
|
|
750
|
+
*/
|
|
751
|
+
async click(target, options = {}) {
|
|
752
|
+
this.logger(chalk.gray(` → Click: ${target}`));
|
|
753
|
+
|
|
754
|
+
await this._waitForStability();
|
|
755
|
+
|
|
756
|
+
const element = await this._findElement(target, options);
|
|
757
|
+
|
|
758
|
+
// Scroll into view if needed
|
|
759
|
+
await element.scrollIntoViewIfNeeded();
|
|
760
|
+
|
|
761
|
+
// Wait for element to be clickable
|
|
762
|
+
await element.waitFor({ state: "visible" });
|
|
763
|
+
|
|
764
|
+
// Perform click - use force:true if element may have CSS animations
|
|
765
|
+
// that Playwright considers "not stable" (e.g., pulsing buttons)
|
|
766
|
+
try {
|
|
767
|
+
await element.click({ timeout: 10000 });
|
|
768
|
+
} catch (clickError) {
|
|
769
|
+
if (clickError.message?.includes('not stable') || clickError.message?.includes('intercept')) {
|
|
770
|
+
this.logger(chalk.gray(" → Retrying click with force:true (animated element)"));
|
|
771
|
+
await element.click({ force: true, timeout: 10000 });
|
|
772
|
+
} else {
|
|
773
|
+
throw clickError;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Wait for any resulting navigation or updates
|
|
778
|
+
await this._waitForStability();
|
|
779
|
+
|
|
780
|
+
this.logger(chalk.green(" ✔ Clicked"));
|
|
781
|
+
return this;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Hover over an element (for dropdowns, tooltips, etc.)
|
|
786
|
+
*/
|
|
787
|
+
async hover(target, options = {}) {
|
|
788
|
+
this.logger(chalk.gray(` → Hover: ${target}`));
|
|
789
|
+
|
|
790
|
+
await this._waitForStability();
|
|
791
|
+
|
|
792
|
+
const element = await this._findElement(target, options);
|
|
793
|
+
await element.scrollIntoViewIfNeeded();
|
|
794
|
+
await element.hover();
|
|
795
|
+
|
|
796
|
+
// Wait for hover effects to appear
|
|
797
|
+
await this.page.waitForTimeout(300);
|
|
798
|
+
|
|
799
|
+
this.logger(chalk.green(" ✔ Hovered"));
|
|
800
|
+
return this;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Type into an input field
|
|
805
|
+
*/
|
|
806
|
+
async type(target, text, options = {}) {
|
|
807
|
+
this.logger(chalk.gray(` → Type into: ${target}`));
|
|
808
|
+
|
|
809
|
+
await this._waitForStability();
|
|
810
|
+
|
|
811
|
+
const element = await this._findElement(target, options);
|
|
812
|
+
|
|
813
|
+
// Clear existing content first
|
|
814
|
+
await element.fill("");
|
|
815
|
+
await element.fill(text);
|
|
816
|
+
|
|
817
|
+
this.logger(chalk.green(" ✔ Typed"));
|
|
818
|
+
return this;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Wait for an element to appear
|
|
823
|
+
*/
|
|
824
|
+
async waitFor(target, options = {}) {
|
|
825
|
+
this.logger(chalk.gray(` → Wait for: ${target}`));
|
|
826
|
+
|
|
827
|
+
await this._findElement(target, {
|
|
828
|
+
...options,
|
|
829
|
+
timeout: options.timeout || 15000,
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
this.logger(chalk.green(" ✔ Element found"));
|
|
833
|
+
return this;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Wait for a specific amount of time
|
|
838
|
+
*/
|
|
839
|
+
async wait(ms) {
|
|
840
|
+
this.logger(chalk.gray(` → Wait ${ms}ms`));
|
|
841
|
+
await this.page.waitForTimeout(ms);
|
|
842
|
+
return this;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Capture a screenshot with optional cropping
|
|
847
|
+
* This is the main output of the capture engine
|
|
848
|
+
*
|
|
849
|
+
* Cropping workflow:
|
|
850
|
+
* 1. If step-level cropConfig is provided, it overrides scenario-level config
|
|
851
|
+
* 2. Crop is applied after capture using Sharp for high-quality results
|
|
852
|
+
* 3. Crop coordinates are automatically scaled by device pixel ratio
|
|
853
|
+
* 4. Cropping is lossless and preserves image quality
|
|
854
|
+
*/
|
|
855
|
+
async capture(name, options = {}) {
|
|
856
|
+
const {
|
|
857
|
+
selector, // Optional: capture specific element
|
|
858
|
+
fullPage, // Capture full scrollable page
|
|
859
|
+
padding = 16, // Padding around element (if selector specified)
|
|
860
|
+
clip, // Manual clip region {x, y, width, height}
|
|
861
|
+
description, // Human-readable description for documentation
|
|
862
|
+
cropConfig: stepCropConfig, // Step-level crop override
|
|
863
|
+
} = options;
|
|
864
|
+
|
|
865
|
+
this.logger(chalk.cyan(`📸 Capturing: ${name}`));
|
|
866
|
+
|
|
867
|
+
// CRITICAL: If privacy masking was configured but injection failed, skip capture
|
|
868
|
+
if (this.privacyConfig && this.privacyConfig.enabled && !this._privacyInjectionOk) {
|
|
869
|
+
console.error(chalk.red(` ✖ PRIVACY: Skipping capture "${name}" — privacy masking injection failed. Fix the issue or use --no-privacy.`));
|
|
870
|
+
return this;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
await this._waitForStability();
|
|
874
|
+
|
|
875
|
+
// Guard: final check for loading indicators before capture
|
|
876
|
+
await this._waitForLoadingComplete(3000);
|
|
877
|
+
|
|
878
|
+
// Guard: catch late auth redirects (e.g., token expiring mid-session)
|
|
879
|
+
const currentUrl = this.page.url();
|
|
880
|
+
if (isAuthRedirectUrl(currentUrl, this._customAuthPatterns)) {
|
|
881
|
+
throw new Error(`Auth redirect detected before capture: ${currentUrl}`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// CRITICAL: Final theme enforcement right before capture
|
|
885
|
+
// This ensures theme classes haven't been reset by React/framework re-renders
|
|
886
|
+
if (this.variantConfig && this.variantConfig.injections?.length > 0) {
|
|
887
|
+
await this.page.evaluate(() => {
|
|
888
|
+
if (window.__RESHOT_THEME_OVERRIDE__) {
|
|
889
|
+
const wanted = window.__RESHOT_THEME_OVERRIDE__;
|
|
890
|
+
document.documentElement.classList.remove("dark", "light");
|
|
891
|
+
document.documentElement.classList.add(wanted);
|
|
892
|
+
document.documentElement.style.colorScheme = wanted;
|
|
893
|
+
document.documentElement.setAttribute("data-theme", wanted);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
// Brief wait for CSS to apply
|
|
897
|
+
await this.page.waitForTimeout(100);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Ensure output directory exists
|
|
901
|
+
const outputPath = path.join(this.outputDir, `${name}.png`);
|
|
902
|
+
fs.ensureDirSync(path.dirname(outputPath));
|
|
903
|
+
|
|
904
|
+
let screenshotOptions = {
|
|
905
|
+
type: "png",
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
if (selector) {
|
|
909
|
+
// Capture specific element with padding
|
|
910
|
+
const element = await this._findElement(selector);
|
|
911
|
+
const box = await element.boundingBox();
|
|
912
|
+
|
|
913
|
+
if (box) {
|
|
914
|
+
screenshotOptions.clip = {
|
|
915
|
+
x: Math.max(0, box.x - padding),
|
|
916
|
+
y: Math.max(0, box.y - padding),
|
|
917
|
+
width: box.width + padding * 2,
|
|
918
|
+
height: box.height + padding * 2,
|
|
919
|
+
};
|
|
920
|
+
} else {
|
|
921
|
+
// Element not visible, capture full page
|
|
922
|
+
screenshotOptions.fullPage = true;
|
|
923
|
+
}
|
|
924
|
+
} else if (clip) {
|
|
925
|
+
screenshotOptions.clip = clip;
|
|
926
|
+
} else if (fullPage) {
|
|
927
|
+
screenshotOptions.fullPage = true;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Capture screenshot to buffer first (for optional cropping)
|
|
931
|
+
const screenshotBuffer = await this.page.screenshot(screenshotOptions);
|
|
932
|
+
|
|
933
|
+
// Determine effective crop config (step overrides scenario)
|
|
934
|
+
const effectiveCropConfig = mergeCropConfigs(
|
|
935
|
+
this.cropConfig,
|
|
936
|
+
stepCropConfig
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
// Apply cropping if configured
|
|
940
|
+
let finalBuffer = screenshotBuffer;
|
|
941
|
+
let wasCropped = false;
|
|
942
|
+
|
|
943
|
+
if (
|
|
944
|
+
effectiveCropConfig &&
|
|
945
|
+
effectiveCropConfig.enabled &&
|
|
946
|
+
isSharpAvailable()
|
|
947
|
+
) {
|
|
948
|
+
try {
|
|
949
|
+
// Get device scale factor for coordinate scaling
|
|
950
|
+
const deviceScaleFactor = await this.page.evaluate(
|
|
951
|
+
() => window.devicePixelRatio || 1
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
finalBuffer = await cropImageBuffer(
|
|
955
|
+
screenshotBuffer,
|
|
956
|
+
effectiveCropConfig,
|
|
957
|
+
{
|
|
958
|
+
deviceScaleFactor,
|
|
959
|
+
}
|
|
960
|
+
);
|
|
961
|
+
wasCropped = true;
|
|
962
|
+
|
|
963
|
+
this.logger(
|
|
964
|
+
chalk.gray(
|
|
965
|
+
` ✂ Cropped to region: ${JSON.stringify(
|
|
966
|
+
effectiveCropConfig.region
|
|
967
|
+
)}`
|
|
968
|
+
)
|
|
969
|
+
);
|
|
970
|
+
} catch (cropError) {
|
|
971
|
+
this.logger(
|
|
972
|
+
chalk.yellow(
|
|
973
|
+
` ⚠ Crop failed: ${cropError.message}, using full screenshot`
|
|
974
|
+
)
|
|
975
|
+
);
|
|
976
|
+
finalBuffer = screenshotBuffer;
|
|
977
|
+
}
|
|
978
|
+
} else if (
|
|
979
|
+
effectiveCropConfig &&
|
|
980
|
+
effectiveCropConfig.enabled &&
|
|
981
|
+
!isSharpAvailable()
|
|
982
|
+
) {
|
|
983
|
+
this.logger(
|
|
984
|
+
chalk.yellow(
|
|
985
|
+
` ⚠ Sharp not installed, skipping crop. Run: npm install sharp`
|
|
986
|
+
)
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Apply style processing (frames, shadow, padding, etc.)
|
|
991
|
+
let wasStyled = false;
|
|
992
|
+
if (this.styleConfig && this.styleConfig.enabled && isStyleAvailable()) {
|
|
993
|
+
// Smart default: skip frame for element screenshots
|
|
994
|
+
const effectiveStyleConfig = selector
|
|
995
|
+
? { ...this.styleConfig, frame: this.styleConfig.frame === undefined ? "none" : this.styleConfig.frame }
|
|
996
|
+
: { ...this.styleConfig };
|
|
997
|
+
|
|
998
|
+
// Detect dark mode from variant config
|
|
999
|
+
if (this.variantConfig?.browserOptions?.colorScheme === "dark") {
|
|
1000
|
+
effectiveStyleConfig._darkMode = true;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Get DPR for accurate scaling
|
|
1004
|
+
const captureDpr = await this.page.evaluate(() => window.devicePixelRatio || 1);
|
|
1005
|
+
finalBuffer = await applyStyle(finalBuffer, effectiveStyleConfig, this.logger, captureDpr);
|
|
1006
|
+
wasStyled = true;
|
|
1007
|
+
} else if (this.styleConfig && this.styleConfig.enabled && !isStyleAvailable()) {
|
|
1008
|
+
this.logger(chalk.yellow(" ⚠ Sharp not installed, skipping style. Run: npm install sharp"));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Write the final buffer to file
|
|
1012
|
+
await fs.writeFile(outputPath, finalBuffer);
|
|
1013
|
+
|
|
1014
|
+
// Record asset metadata
|
|
1015
|
+
this.capturedAssets.push({
|
|
1016
|
+
name,
|
|
1017
|
+
path: outputPath,
|
|
1018
|
+
description,
|
|
1019
|
+
capturedAt: new Date().toISOString(),
|
|
1020
|
+
viewport: this.viewport,
|
|
1021
|
+
cropped: wasCropped,
|
|
1022
|
+
cropConfig: wasCropped ? effectiveCropConfig : undefined,
|
|
1023
|
+
styled: wasStyled,
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
this.logger(chalk.green(` ✔ Saved: ${outputPath}`));
|
|
1027
|
+
return this;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Capture with hover state visible
|
|
1032
|
+
* Useful for dropdowns, menus, tooltips
|
|
1033
|
+
*/
|
|
1034
|
+
async captureWithHover(name, hoverTarget, options = {}) {
|
|
1035
|
+
this.logger(chalk.cyan(`📸 Capturing with hover: ${name}`));
|
|
1036
|
+
|
|
1037
|
+
await this.hover(hoverTarget);
|
|
1038
|
+
await this.wait(200); // Let hover animation complete
|
|
1039
|
+
await this.capture(name, options);
|
|
1040
|
+
|
|
1041
|
+
// Move mouse away to clear hover
|
|
1042
|
+
await this.page.mouse.move(0, 0);
|
|
1043
|
+
|
|
1044
|
+
return this;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Capture a sequence of steps as a GIF or series of images
|
|
1049
|
+
*/
|
|
1050
|
+
async captureSequence(name, steps, options = {}) {
|
|
1051
|
+
const { frameDelay = 500 } = options;
|
|
1052
|
+
const frames = [];
|
|
1053
|
+
|
|
1054
|
+
this.logger(chalk.cyan(`🎬 Capturing sequence: ${name}`));
|
|
1055
|
+
|
|
1056
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1057
|
+
const step = steps[i];
|
|
1058
|
+
|
|
1059
|
+
// Execute the step action
|
|
1060
|
+
if (step.action) {
|
|
1061
|
+
await this[step.action](...(step.args || []));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Capture frame
|
|
1065
|
+
const framePath = path.join(
|
|
1066
|
+
this.outputDir,
|
|
1067
|
+
`${name}-frame-${i.toString().padStart(3, "0")}.png`
|
|
1068
|
+
);
|
|
1069
|
+
fs.ensureDirSync(path.dirname(framePath));
|
|
1070
|
+
|
|
1071
|
+
await this.page.screenshot({ path: framePath });
|
|
1072
|
+
frames.push(framePath);
|
|
1073
|
+
|
|
1074
|
+
await this.wait(frameDelay);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
this.logger(chalk.green(` ✔ Captured ${frames.length} frames`));
|
|
1078
|
+
|
|
1079
|
+
return frames;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Wait for page to be stable (no network activity, no animations, no unrendered i18n keys)
|
|
1084
|
+
*/
|
|
1085
|
+
async _waitForStability() {
|
|
1086
|
+
try {
|
|
1087
|
+
// Wait for network to be idle (no requests for 500ms)
|
|
1088
|
+
await this.page.waitForLoadState("networkidle", { timeout: 5000 });
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
// Network might never be fully idle - check for pending data fetches
|
|
1091
|
+
try {
|
|
1092
|
+
await this.page.waitForFunction(
|
|
1093
|
+
() => {
|
|
1094
|
+
// Check if there are recent fetch/XHR requests still in progress
|
|
1095
|
+
const entries = performance.getEntriesByType("resource");
|
|
1096
|
+
const recentDataFetches = entries.filter((e) => {
|
|
1097
|
+
const isFetch =
|
|
1098
|
+
e.initiatorType === "fetch" || e.initiatorType === "xmlhttprequest";
|
|
1099
|
+
const isRecent = performance.now() - e.startTime < 2000;
|
|
1100
|
+
const isNotComplete = e.duration === 0;
|
|
1101
|
+
return isFetch && isRecent && isNotComplete;
|
|
1102
|
+
});
|
|
1103
|
+
return recentDataFetches.length === 0;
|
|
1104
|
+
},
|
|
1105
|
+
{ timeout: 2000 }
|
|
1106
|
+
);
|
|
1107
|
+
} catch (_e) {
|
|
1108
|
+
// Continue anyway - best effort
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Wait for any pending React/Vue hydration and i18n to complete
|
|
1113
|
+
// Check for common i18n key patterns that indicate translations haven't loaded
|
|
1114
|
+
try {
|
|
1115
|
+
await this.page.waitForFunction(
|
|
1116
|
+
() => {
|
|
1117
|
+
// Look for common unrendered i18n key patterns in visible text
|
|
1118
|
+
const body = document.body;
|
|
1119
|
+
if (!body) return true;
|
|
1120
|
+
|
|
1121
|
+
const text = body.innerText || "";
|
|
1122
|
+
|
|
1123
|
+
// Common i18n key patterns that indicate translations haven't loaded yet
|
|
1124
|
+
const i18nKeyPatterns = [
|
|
1125
|
+
/[a-z]+\.[a-z]+\.[a-z]+/i, // nested.key.pattern
|
|
1126
|
+
/[a-z]+:[a-z]+\.[a-z]+/i, // namespace:key.pattern
|
|
1127
|
+
/\{\{[^}]+\}\}/, // {{interpolation}}
|
|
1128
|
+
/\$t\([^)]+\)/, // $t('key')
|
|
1129
|
+
];
|
|
1130
|
+
|
|
1131
|
+
// Check if visible text contains raw i18n keys
|
|
1132
|
+
// This is a heuristic - we check if there are suspiciously many dotted identifiers
|
|
1133
|
+
const dottedMatches = text.match(/\b[a-z_]+\.[a-z_]+\b/gi) || [];
|
|
1134
|
+
|
|
1135
|
+
// Filter to only those that look like translation keys (all lowercase with dots)
|
|
1136
|
+
const suspiciousKeys = dottedMatches.filter((match) => {
|
|
1137
|
+
// Skip common non-i18n patterns
|
|
1138
|
+
if (
|
|
1139
|
+
match.includes(".com") ||
|
|
1140
|
+
match.includes(".org") ||
|
|
1141
|
+
match.includes(".io")
|
|
1142
|
+
)
|
|
1143
|
+
return false;
|
|
1144
|
+
if (match.match(/\d+\.\d+/)) return false; // version numbers
|
|
1145
|
+
if (match === "e.g" || match === "i.e") return false;
|
|
1146
|
+
// Keys are typically all lowercase with underscores/dots
|
|
1147
|
+
return match === match.toLowerCase() && match.length > 5;
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// If we find more than 3 suspicious keys visible, translations probably haven't loaded
|
|
1151
|
+
return suspiciousKeys.length < 3;
|
|
1152
|
+
},
|
|
1153
|
+
{ timeout: 3000 }
|
|
1154
|
+
);
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
// Timeout is okay - we'll proceed anyway
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Additional wait for any animations/transitions
|
|
1160
|
+
await this.page.waitForTimeout(200);
|
|
1161
|
+
|
|
1162
|
+
// Wait for loading skeletons/spinners to disappear
|
|
1163
|
+
await this._waitForLoadingComplete();
|
|
1164
|
+
|
|
1165
|
+
// Custom ready-state hook (user-configured per scenario)
|
|
1166
|
+
if (this.waitForReady) {
|
|
1167
|
+
const result = await this._waitForCustomReady(this.waitForReady);
|
|
1168
|
+
if (!result.ready) {
|
|
1169
|
+
this.logger(chalk.yellow(` ⚠ Custom ready check failed: ${result.reason}`));
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Wait for loading indicators (skeletons, spinners, etc.) to disappear
|
|
1176
|
+
* Increased timeout for SaaS apps that may have data fetching
|
|
1177
|
+
*/
|
|
1178
|
+
async _waitForLoadingComplete(maxWait = 5000) {
|
|
1179
|
+
// Selectors for actual loading states (not decorative animations)
|
|
1180
|
+
const loadingSelectors = [
|
|
1181
|
+
'[class*="skeleton"]',
|
|
1182
|
+
'[class*="Skeleton"]',
|
|
1183
|
+
'[class*="shimmer"]',
|
|
1184
|
+
'[class*="loading"]',
|
|
1185
|
+
'[class*="Loading"]',
|
|
1186
|
+
'[class*="spinner"]',
|
|
1187
|
+
'[class*="Spinner"]',
|
|
1188
|
+
'[class*="loader"]',
|
|
1189
|
+
'[class*="Loader"]',
|
|
1190
|
+
'[role="progressbar"]',
|
|
1191
|
+
'[aria-busy="true"]',
|
|
1192
|
+
'[data-loading="true"]',
|
|
1193
|
+
"[data-skeleton]",
|
|
1194
|
+
// Additional common patterns
|
|
1195
|
+
".placeholder-glow",
|
|
1196
|
+
".placeholder-wave", // Bootstrap
|
|
1197
|
+
'[data-testid*="loading"]',
|
|
1198
|
+
'[data-testid*="skeleton"]',
|
|
1199
|
+
".suspense-fallback",
|
|
1200
|
+
".lazy-loading",
|
|
1201
|
+
];
|
|
1202
|
+
|
|
1203
|
+
// Selectors that might be decorative animations (small status indicators)
|
|
1204
|
+
// We check these but require them to be larger than a certain size
|
|
1205
|
+
const decorativeSelectors = [
|
|
1206
|
+
'[class*="pulse"]',
|
|
1207
|
+
'[class*="animate-pulse"]',
|
|
1208
|
+
];
|
|
1209
|
+
|
|
1210
|
+
const startTime = Date.now();
|
|
1211
|
+
let attempts = 0;
|
|
1212
|
+
let consecutiveNoLoading = 0;
|
|
1213
|
+
|
|
1214
|
+
while (Date.now() - startTime < maxWait) {
|
|
1215
|
+
attempts++;
|
|
1216
|
+
let loadingFound = false;
|
|
1217
|
+
|
|
1218
|
+
// Check strict loading selectors
|
|
1219
|
+
for (const selector of loadingSelectors) {
|
|
1220
|
+
try {
|
|
1221
|
+
const count = await this.page.locator(selector).count();
|
|
1222
|
+
if (count > 0) {
|
|
1223
|
+
const visible = await this.page
|
|
1224
|
+
.locator(selector)
|
|
1225
|
+
.first()
|
|
1226
|
+
.isVisible()
|
|
1227
|
+
.catch(() => false);
|
|
1228
|
+
if (visible) {
|
|
1229
|
+
loadingFound = true;
|
|
1230
|
+
consecutiveNoLoading = 0;
|
|
1231
|
+
break;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
} catch {
|
|
1235
|
+
// Selector didn't match, that's fine
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Check decorative selectors but only if they're large enough to be actual skeletons
|
|
1240
|
+
if (!loadingFound) {
|
|
1241
|
+
for (const selector of decorativeSelectors) {
|
|
1242
|
+
try {
|
|
1243
|
+
const elements = await this.page.locator(selector).all();
|
|
1244
|
+
for (const el of elements) {
|
|
1245
|
+
const visible = await el.isVisible().catch(() => false);
|
|
1246
|
+
if (visible) {
|
|
1247
|
+
// Check size - decorative dots are typically small (< 50px)
|
|
1248
|
+
const box = await el.boundingBox().catch(() => null);
|
|
1249
|
+
if (box && (box.width > 50 || box.height > 50)) {
|
|
1250
|
+
// This is likely a skeleton/loading placeholder, not a decorative dot
|
|
1251
|
+
loadingFound = true;
|
|
1252
|
+
consecutiveNoLoading = 0;
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (loadingFound) break;
|
|
1258
|
+
} catch {
|
|
1259
|
+
// Selector didn't match, that's fine
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (!loadingFound) {
|
|
1265
|
+
consecutiveNoLoading++;
|
|
1266
|
+
// Wait for 5 consecutive checks with no loading to ensure stability
|
|
1267
|
+
if (consecutiveNoLoading >= 5) {
|
|
1268
|
+
if (attempts > 5) {
|
|
1269
|
+
this.logger &&
|
|
1270
|
+
this.logger(
|
|
1271
|
+
chalk.dim(
|
|
1272
|
+
` Loading indicators cleared after ${attempts} checks`
|
|
1273
|
+
)
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Wait a bit before checking again
|
|
1281
|
+
await this.page.waitForTimeout(200);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
this.logger &&
|
|
1285
|
+
this.logger(
|
|
1286
|
+
chalk.yellow(
|
|
1287
|
+
` Warning: Loading indicators still present after ${maxWait}ms, proceeding anyway`
|
|
1288
|
+
)
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Wait for a custom ready condition configured per-scenario.
|
|
1294
|
+
* Supports CSS selector presence and/or a JS expression evaluating to true.
|
|
1295
|
+
*
|
|
1296
|
+
* @param {Object} config - { selector?: string, expression?: string, timeout?: number }
|
|
1297
|
+
* @returns {Promise<{ready: boolean, reason?: string}>}
|
|
1298
|
+
*/
|
|
1299
|
+
async _waitForCustomReady(config) {
|
|
1300
|
+
const timeout = config.timeout || 10000;
|
|
1301
|
+
|
|
1302
|
+
// Selector check: wait for the element to be attached to the DOM
|
|
1303
|
+
if (config.selector) {
|
|
1304
|
+
try {
|
|
1305
|
+
await this.page
|
|
1306
|
+
.locator(config.selector)
|
|
1307
|
+
.first()
|
|
1308
|
+
.waitFor({ state: "attached", timeout });
|
|
1309
|
+
} catch {
|
|
1310
|
+
return { ready: false, reason: `Selector "${config.selector}" not found within ${timeout}ms` };
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Expression check: wait for a JS expression to return truthy
|
|
1315
|
+
if (config.expression) {
|
|
1316
|
+
try {
|
|
1317
|
+
await this.page.waitForFunction(config.expression, { timeout });
|
|
1318
|
+
} catch {
|
|
1319
|
+
return { ready: false, reason: `Expression "${config.expression}" did not become truthy within ${timeout}ms` };
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
return { ready: true };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Detect error state on the current page
|
|
1328
|
+
* Checks explicit data attributes, custom selectors, and heuristic patterns
|
|
1329
|
+
* @param {Object} options - Detection options
|
|
1330
|
+
* @param {string[]} options.errorSelectors - Custom error selectors to check
|
|
1331
|
+
* @param {boolean} options.errorHeuristics - Whether to use heuristic detection
|
|
1332
|
+
* @returns {Promise<{hasError: boolean, errorType: string|null, errorMessage: string|null}>}
|
|
1333
|
+
*/
|
|
1334
|
+
async _detectErrorState(options = {}) {
|
|
1335
|
+
const {
|
|
1336
|
+
errorSelectors = ["[data-testid='page-error']", "[data-error-type]"],
|
|
1337
|
+
errorHeuristics = true,
|
|
1338
|
+
} = options;
|
|
1339
|
+
|
|
1340
|
+
try {
|
|
1341
|
+
return await this.page.evaluate(
|
|
1342
|
+
({ selectors, useHeuristics }) => {
|
|
1343
|
+
// 1. Check explicit data attributes (fast, deterministic)
|
|
1344
|
+
const errorEl = document.querySelector("[data-testid='page-error']");
|
|
1345
|
+
if (errorEl) {
|
|
1346
|
+
const errorType =
|
|
1347
|
+
errorEl.getAttribute("data-error-type") || "unknown";
|
|
1348
|
+
const errorMessage =
|
|
1349
|
+
errorEl.textContent?.trim().slice(0, 200) || "Page error detected";
|
|
1350
|
+
return { hasError: true, errorType, errorMessage };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Also check data-error-type on any element
|
|
1354
|
+
const errorTypeEl = document.querySelector("[data-error-type]");
|
|
1355
|
+
if (errorTypeEl) {
|
|
1356
|
+
const errorType =
|
|
1357
|
+
errorTypeEl.getAttribute("data-error-type") || "unknown";
|
|
1358
|
+
const errorMessage =
|
|
1359
|
+
errorTypeEl.textContent?.trim().slice(0, 200) ||
|
|
1360
|
+
"Error state detected";
|
|
1361
|
+
return { hasError: true, errorType, errorMessage };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// 2. Check custom error selectors from config
|
|
1365
|
+
for (const selector of selectors) {
|
|
1366
|
+
if (
|
|
1367
|
+
selector === "[data-testid='page-error']" ||
|
|
1368
|
+
selector === "[data-error-type]"
|
|
1369
|
+
)
|
|
1370
|
+
continue; // Already checked
|
|
1371
|
+
try {
|
|
1372
|
+
const el = document.querySelector(selector);
|
|
1373
|
+
if (el) {
|
|
1374
|
+
const style = window.getComputedStyle(el);
|
|
1375
|
+
if (
|
|
1376
|
+
style.display !== "none" &&
|
|
1377
|
+
style.visibility !== "hidden"
|
|
1378
|
+
) {
|
|
1379
|
+
return {
|
|
1380
|
+
hasError: true,
|
|
1381
|
+
errorType: "custom-selector",
|
|
1382
|
+
errorMessage: `Error selector matched: ${selector}`,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
} catch (e) {
|
|
1387
|
+
// Invalid selector, skip
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// 3. Heuristic patterns (fallback)
|
|
1392
|
+
if (useHeuristics) {
|
|
1393
|
+
const bodyText = document.body?.innerText || "";
|
|
1394
|
+
const errorPatterns = [
|
|
1395
|
+
/failed to load/i,
|
|
1396
|
+
/something went wrong/i,
|
|
1397
|
+
/unable to load/i,
|
|
1398
|
+
/error loading/i,
|
|
1399
|
+
/could not load/i,
|
|
1400
|
+
/an error occurred/i,
|
|
1401
|
+
];
|
|
1402
|
+
|
|
1403
|
+
for (const pattern of errorPatterns) {
|
|
1404
|
+
if (pattern.test(bodyText)) {
|
|
1405
|
+
// Structural check: error text should be in a centered/prominent element
|
|
1406
|
+
const candidates = document.querySelectorAll(
|
|
1407
|
+
".text-center, .text-destructive, [class*='error'], [class*='Error']"
|
|
1408
|
+
);
|
|
1409
|
+
for (const candidate of candidates) {
|
|
1410
|
+
if (
|
|
1411
|
+
pattern.test(candidate.textContent || "") &&
|
|
1412
|
+
candidate.offsetHeight > 50
|
|
1413
|
+
) {
|
|
1414
|
+
return {
|
|
1415
|
+
hasError: true,
|
|
1416
|
+
errorType: "heuristic",
|
|
1417
|
+
errorMessage: candidate.textContent
|
|
1418
|
+
?.trim()
|
|
1419
|
+
.slice(0, 200),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
return { hasError: false, errorType: null, errorMessage: null };
|
|
1428
|
+
},
|
|
1429
|
+
{ selectors: errorSelectors, useHeuristics: errorHeuristics }
|
|
1430
|
+
);
|
|
1431
|
+
} catch (e) {
|
|
1432
|
+
// Page may be navigating, return no error
|
|
1433
|
+
return { hasError: false, errorType: null, errorMessage: null };
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Race between page ready state and error state detection
|
|
1439
|
+
* @param {string} readySelector - Selector indicating page is ready
|
|
1440
|
+
* @param {Object} options - Options
|
|
1441
|
+
* @param {number} options.timeout - Max time to wait
|
|
1442
|
+
* @param {string[]} options.errorSelectors - Error selectors
|
|
1443
|
+
* @param {boolean} options.errorHeuristics - Enable heuristic detection
|
|
1444
|
+
* @returns {Promise<{status: 'ready'|'error'|'timeout', errorDetails?: Object}>}
|
|
1445
|
+
*/
|
|
1446
|
+
async waitForReadyOrError(readySelector, options = {}) {
|
|
1447
|
+
const {
|
|
1448
|
+
timeout = 15000,
|
|
1449
|
+
errorSelectors = ["[data-testid='page-error']", "[data-error-type]"],
|
|
1450
|
+
errorHeuristics = true,
|
|
1451
|
+
} = options;
|
|
1452
|
+
|
|
1453
|
+
const startTime = Date.now();
|
|
1454
|
+
const pollInterval = 300;
|
|
1455
|
+
|
|
1456
|
+
// Start waitForSelector in background
|
|
1457
|
+
const readyPromise = this.page
|
|
1458
|
+
.locator(readySelector)
|
|
1459
|
+
.first()
|
|
1460
|
+
.waitFor({ state: "visible", timeout })
|
|
1461
|
+
.then(() => ({ status: "ready" }))
|
|
1462
|
+
.catch(() => null); // Will be handled by timeout
|
|
1463
|
+
|
|
1464
|
+
// Poll for error state
|
|
1465
|
+
while (Date.now() - startTime < timeout) {
|
|
1466
|
+
// Check if ready selector resolved
|
|
1467
|
+
const readyResult = await Promise.race([
|
|
1468
|
+
readyPromise,
|
|
1469
|
+
new Promise((resolve) => setTimeout(() => resolve(null), pollInterval)),
|
|
1470
|
+
]);
|
|
1471
|
+
|
|
1472
|
+
if (readyResult) {
|
|
1473
|
+
return readyResult;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Check for error state
|
|
1477
|
+
const errorState = await this._detectErrorState({
|
|
1478
|
+
errorSelectors,
|
|
1479
|
+
errorHeuristics,
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
if (errorState.hasError) {
|
|
1483
|
+
return { status: "error", errorDetails: errorState };
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
return { status: "timeout" };
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Verify page has meaningful content before capture (optional)
|
|
1492
|
+
* @param {Object} options - Verification options
|
|
1493
|
+
* @param {number} options.minContentLength - Minimum body text length
|
|
1494
|
+
* @param {string[]} options.rejectSelectors - Selectors that indicate bad content
|
|
1495
|
+
* @returns {Promise<{valid: boolean, reason?: string}>}
|
|
1496
|
+
*/
|
|
1497
|
+
async _verifyContent(options = {}) {
|
|
1498
|
+
const { minContentLength = 100, rejectSelectors = [] } = options;
|
|
1499
|
+
|
|
1500
|
+
try {
|
|
1501
|
+
return await this.page.evaluate(
|
|
1502
|
+
({ minLen, rejectSels }) => {
|
|
1503
|
+
const bodyText = (document.body?.innerText || "").trim();
|
|
1504
|
+
|
|
1505
|
+
if (bodyText.length < minLen) {
|
|
1506
|
+
return {
|
|
1507
|
+
valid: false,
|
|
1508
|
+
reason: `Page content too short (${bodyText.length} chars, minimum ${minLen})`,
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
for (const sel of rejectSels) {
|
|
1513
|
+
try {
|
|
1514
|
+
if (document.querySelector(sel)) {
|
|
1515
|
+
return {
|
|
1516
|
+
valid: false,
|
|
1517
|
+
reason: `Reject selector found: ${sel}`,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
} catch (e) {
|
|
1521
|
+
// Invalid selector, skip
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return { valid: true };
|
|
1526
|
+
},
|
|
1527
|
+
{ minLen: minContentLength, rejectSels: rejectSelectors }
|
|
1528
|
+
);
|
|
1529
|
+
} catch (e) {
|
|
1530
|
+
return { valid: true }; // Don't block on evaluation errors
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Get all captured assets
|
|
1536
|
+
*/
|
|
1537
|
+
getAssets() {
|
|
1538
|
+
return this.capturedAssets;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Close the browser
|
|
1543
|
+
*/
|
|
1544
|
+
async close() {
|
|
1545
|
+
if (this.browser) {
|
|
1546
|
+
await this.browser.close();
|
|
1547
|
+
this.browser = null;
|
|
1548
|
+
this.page = null;
|
|
1549
|
+
this.context = null;
|
|
1550
|
+
}
|
|
1551
|
+
this.logger(chalk.green("✔ Browser closed"));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Run a capture script
|
|
1556
|
+
* Script is an array of step objects
|
|
1557
|
+
*/
|
|
1558
|
+
async runScript(script) {
|
|
1559
|
+
for (const step of script) {
|
|
1560
|
+
const { action, ...params } = step;
|
|
1561
|
+
|
|
1562
|
+
if (!this[action]) {
|
|
1563
|
+
throw new Error(`Unknown action: ${action}`);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Handle different action signatures
|
|
1567
|
+
switch (action) {
|
|
1568
|
+
case "goto":
|
|
1569
|
+
await this.goto(params.url, params);
|
|
1570
|
+
break;
|
|
1571
|
+
case "click":
|
|
1572
|
+
await this.click(params.target, params);
|
|
1573
|
+
break;
|
|
1574
|
+
case "hover":
|
|
1575
|
+
await this.hover(params.target, params);
|
|
1576
|
+
break;
|
|
1577
|
+
case "type":
|
|
1578
|
+
await this.type(params.target, params.text, params);
|
|
1579
|
+
break;
|
|
1580
|
+
case "wait":
|
|
1581
|
+
await this.wait(params.ms || params.duration || 1000);
|
|
1582
|
+
break;
|
|
1583
|
+
case "waitFor":
|
|
1584
|
+
await this.waitFor(params.target, params);
|
|
1585
|
+
break;
|
|
1586
|
+
case "capture":
|
|
1587
|
+
await this.capture(params.name, params);
|
|
1588
|
+
break;
|
|
1589
|
+
case "captureWithHover":
|
|
1590
|
+
await this.captureWithHover(params.name, params.hoverTarget, params);
|
|
1591
|
+
break;
|
|
1592
|
+
default:
|
|
1593
|
+
this.logger(chalk.yellow(` ⚠ Unknown action: ${action}`));
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
return this.capturedAssets;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Helper to create and run a capture script
|
|
1603
|
+
*/
|
|
1604
|
+
async function runCaptureScript(script, options = {}) {
|
|
1605
|
+
const engine = new CaptureEngine(options);
|
|
1606
|
+
|
|
1607
|
+
try {
|
|
1608
|
+
await engine.init();
|
|
1609
|
+
const assets = await engine.runScript(script);
|
|
1610
|
+
return { success: true, assets };
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
console.error(chalk.red(`Capture failed: ${error.message}`));
|
|
1613
|
+
return { success: false, error: error.message };
|
|
1614
|
+
} finally {
|
|
1615
|
+
await engine.close();
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
module.exports = {
|
|
1620
|
+
CaptureEngine,
|
|
1621
|
+
runCaptureScript,
|
|
1622
|
+
isAuthRedirectUrl,
|
|
1623
|
+
};
|