@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.
Files changed (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,318 @@
1
+ // output-path-template.js - Output path templating with variable interpolation
2
+ // Allows developers to define custom output paths: "./docs/assets/{{locale}}/{{name}}.png"
3
+
4
+ const path = require("path");
5
+ const fs = require("fs-extra");
6
+
7
+ /**
8
+ * Supported template variables:
9
+ * - {{scenario}} / {{scenarioKey}} - Scenario key
10
+ * - {{scenarioName}} - Human-readable scenario name
11
+ * - {{name}} / {{assetName}} - Asset/screenshot name
12
+ * - {{step}} / {{stepIndex}} - Step index (1-based)
13
+ * - {{locale}} - Current locale from variant (e.g., "en", "ko")
14
+ * - {{role}} - Current role from variant (e.g., "admin", "viewer")
15
+ * - {{theme}} - Current theme from variant (e.g., "light", "dark")
16
+ * - {{variant}} - Full variant slug (e.g., "locale-en_role-admin_theme-dark")
17
+ * - {{timestamp}} - ISO timestamp for this run
18
+ * - {{date}} - Date portion (YYYY-MM-DD)
19
+ * - {{time}} - Time portion (HH-MM-SS)
20
+ * - {{viewport}} - Viewport preset name or WxH (e.g., "desktop", "1280x720")
21
+ * - {{viewportWidth}} - Viewport width
22
+ * - {{viewportHeight}} - Viewport height
23
+ * - {{ext}} / {{extension}} - File extension (default: "png")
24
+ *
25
+ * Custom dimension variables are also supported automatically from variant config.
26
+ */
27
+
28
+ /**
29
+ * Default output template when none specified
30
+ */
31
+ const DEFAULT_OUTPUT_TEMPLATE = ".reshot/output/{{scenario}}/{{timestamp}}/{{variant}}/{{name}}.{{ext}}";
32
+
33
+ /**
34
+ * Template presets for common use cases
35
+ */
36
+ const TEMPLATE_PRESETS = {
37
+ default: DEFAULT_OUTPUT_TEMPLATE,
38
+
39
+ // Flat structure - all assets in one folder per scenario
40
+ flat: ".reshot/output/{{scenario}}/{{name}}_{{variant}}.{{ext}}",
41
+
42
+ // Locale-first organization (good for i18n documentation)
43
+ "locale-first": "./docs/{{locale}}/{{scenario}}/{{name}}.{{ext}}",
44
+
45
+ // Versioned by timestamp
46
+ versioned: ".reshot/output/{{scenario}}/{{date}}_{{time}}/{{variant}}/{{name}}.{{ext}}",
47
+
48
+ // Simple docs structure
49
+ docs: "./docs/assets/{{scenario}}/{{name}}.{{ext}}",
50
+
51
+ // Variant-focused (good for matrix testing)
52
+ "variant-matrix": ".reshot/output/{{scenario}}/{{locale}}/{{role}}/{{theme}}/{{name}}.{{ext}}",
53
+
54
+ // GitHub Pages / Static site friendly
55
+ "static-site": "./public/screenshots/{{locale}}/{{scenario}}/{{name}}.{{ext}}",
56
+
57
+ // CI/CD artifacts
58
+ ci: "./artifacts/screenshots/{{scenario}}/{{viewport}}/{{variant}}/{{name}}.{{ext}}",
59
+ };
60
+
61
+ /**
62
+ * Parse a template string and extract all variable names
63
+ * @param {string} template - Template string with {{variable}} placeholders
64
+ * @returns {string[]} Array of variable names found in template
65
+ */
66
+ function parseTemplateVariables(template) {
67
+ const regex = /\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g;
68
+ const variables = [];
69
+ let match;
70
+
71
+ while ((match = regex.exec(template)) !== null) {
72
+ if (!variables.includes(match[1])) {
73
+ variables.push(match[1]);
74
+ }
75
+ }
76
+
77
+ return variables;
78
+ }
79
+
80
+ /**
81
+ * Build context object from capture state
82
+ * @param {Object} options - Capture context options
83
+ * @returns {Object} Template context with all available variables
84
+ */
85
+ function buildTemplateContext(options = {}) {
86
+ const {
87
+ scenario = {},
88
+ assetName = "screenshot",
89
+ stepIndex = 0,
90
+ variant = {},
91
+ timestamp = null,
92
+ viewport = { width: 1280, height: 720 },
93
+ viewportPresetName = null,
94
+ extension = "png",
95
+ customVariables = {},
96
+ } = options;
97
+
98
+ // Handle timestamp - can be a Date, ISO string, or pre-formatted string (YYYY-MM-DD_HH-MM-SS)
99
+ let isoTimestamp;
100
+ let datePart;
101
+ let timePart;
102
+
103
+ if (timestamp) {
104
+ // Check if timestamp is already in our formatted format (YYYY-MM-DD_HH-MM-SS)
105
+ const formattedMatch = String(timestamp).match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})$/);
106
+ if (formattedMatch) {
107
+ // Already formatted, use directly
108
+ isoTimestamp = timestamp;
109
+ datePart = formattedMatch[1];
110
+ timePart = formattedMatch[2];
111
+ } else {
112
+ // Try to parse as Date
113
+ const now = new Date(timestamp);
114
+ if (!isNaN(now.getTime())) {
115
+ isoTimestamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
116
+ [datePart, timePart] = isoTimestamp.split("_");
117
+ } else {
118
+ // Fall back to current time
119
+ const fallback = new Date();
120
+ isoTimestamp = fallback.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
121
+ [datePart, timePart] = isoTimestamp.split("_");
122
+ }
123
+ }
124
+ } else {
125
+ // No timestamp provided, use current time
126
+ const now = new Date();
127
+ isoTimestamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
128
+ [datePart, timePart] = isoTimestamp.split("_");
129
+ }
130
+
131
+ // Build variant slug from all variant dimensions
132
+ const variantSlug = Object.entries(variant)
133
+ .filter(([_, v]) => v != null)
134
+ .map(([k, v]) => `${k}-${v}`)
135
+ .join("_") || "default";
136
+
137
+ // Core context
138
+ const context = {
139
+ // Scenario info
140
+ scenario: scenario.key || "unknown",
141
+ scenarioKey: scenario.key || "unknown",
142
+ scenarioName: (scenario.name || scenario.key || "unknown").replace(/[^a-zA-Z0-9-_]/g, "-"),
143
+
144
+ // Asset info
145
+ name: assetName,
146
+ assetName: assetName,
147
+ step: String(stepIndex + 1), // 1-based for human readability
148
+ stepIndex: String(stepIndex),
149
+
150
+ // Variant info - individual dimensions
151
+ locale: variant.locale || "default",
152
+ role: variant.role || "default",
153
+ theme: variant.theme || "default",
154
+ variant: variantSlug,
155
+
156
+ // Timestamp info
157
+ timestamp: isoTimestamp,
158
+ date: datePart,
159
+ time: timePart,
160
+
161
+ // Viewport info
162
+ viewport: viewportPresetName || `${viewport.width}x${viewport.height}`,
163
+ viewportWidth: String(viewport.width),
164
+ viewportHeight: String(viewport.height),
165
+
166
+ // Extension
167
+ ext: extension,
168
+ extension: extension,
169
+ };
170
+
171
+ // Add any custom variant dimensions dynamically
172
+ for (const [key, value] of Object.entries(variant)) {
173
+ if (!context[key]) {
174
+ context[key] = String(value);
175
+ }
176
+ }
177
+
178
+ // Add any custom variables provided
179
+ for (const [key, value] of Object.entries(customVariables)) {
180
+ context[key] = String(value);
181
+ }
182
+
183
+ return context;
184
+ }
185
+
186
+ /**
187
+ * Resolve a template string with context values
188
+ * @param {string} template - Template string or preset name
189
+ * @param {Object} context - Context object with variable values
190
+ * @returns {string} Resolved path string
191
+ */
192
+ function resolveTemplate(template, context = {}) {
193
+ // Check if template is a preset name
194
+ const actualTemplate = TEMPLATE_PRESETS[template] || template || DEFAULT_OUTPUT_TEMPLATE;
195
+
196
+ // Replace all {{variable}} placeholders
197
+ let resolved = actualTemplate.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (match, varName) => {
198
+ const value = context[varName];
199
+ if (value !== undefined && value !== null) {
200
+ return String(value);
201
+ }
202
+ // Unknown variables are replaced with empty string
203
+ return "";
204
+ });
205
+
206
+ // Clean up any double slashes from empty replacements
207
+ resolved = resolved.replace(/\/+/g, "/");
208
+
209
+ // Remove leading ./ if present and path is absolute
210
+ if (path.isAbsolute(resolved.replace(/^\.\//, ""))) {
211
+ resolved = resolved.replace(/^\.\//, "");
212
+ }
213
+
214
+ return resolved;
215
+ }
216
+
217
+ /**
218
+ * Resolve output path for a capture
219
+ * @param {string} templateOrPreset - Template string, preset name, or null for default
220
+ * @param {Object} options - Capture context options (see buildTemplateContext)
221
+ * @returns {string} Fully resolved output file path
222
+ */
223
+ function resolveOutputPath(templateOrPreset, options = {}) {
224
+ const context = buildTemplateContext(options);
225
+ return resolveTemplate(templateOrPreset, context);
226
+ }
227
+
228
+ /**
229
+ * Resolve output directory (without filename) for a capture run
230
+ * Useful for creating directories before capture
231
+ * @param {string} templateOrPreset - Template string or preset name
232
+ * @param {Object} options - Capture context options
233
+ * @returns {string} Resolved directory path
234
+ */
235
+ function resolveOutputDirectory(templateOrPreset, options = {}) {
236
+ const fullPath = resolveOutputPath(templateOrPreset, options);
237
+ return path.dirname(fullPath);
238
+ }
239
+
240
+ /**
241
+ * Validate a template string
242
+ * @param {string} template - Template string to validate
243
+ * @returns {{ valid: boolean, error?: string, variables?: string[] }}
244
+ */
245
+ function validateTemplate(template) {
246
+ if (!template || typeof template !== "string") {
247
+ return { valid: false, error: "Template must be a non-empty string" };
248
+ }
249
+
250
+ // Check for valid template syntax
251
+ const variables = parseTemplateVariables(template);
252
+
253
+ // Check for unbalanced braces
254
+ const openBraces = (template.match(/\{\{/g) || []).length;
255
+ const closeBraces = (template.match(/\}\}/g) || []).length;
256
+ if (openBraces !== closeBraces) {
257
+ return { valid: false, error: "Unbalanced template braces - check {{}} syntax" };
258
+ }
259
+
260
+ // Check for invalid variable syntax
261
+ const invalidMatches = template.match(/\{\{[^}]*[^a-zA-Z0-9_}][^}]*\}\}/g);
262
+ if (invalidMatches) {
263
+ return {
264
+ valid: false,
265
+ error: `Invalid variable syntax: ${invalidMatches.join(", ")}. Variables must be alphanumeric with underscores.`
266
+ };
267
+ }
268
+
269
+ // Must include at least {{name}} or {{assetName}} to be useful
270
+ if (!variables.includes("name") && !variables.includes("assetName")) {
271
+ return {
272
+ valid: true,
273
+ warning: "Template should include {{name}} or {{assetName}} to distinguish different captures",
274
+ variables,
275
+ };
276
+ }
277
+
278
+ return { valid: true, variables };
279
+ }
280
+
281
+ /**
282
+ * Get list of available template presets
283
+ * @returns {Array<{name: string, template: string, description: string}>}
284
+ */
285
+ function getTemplatePresets() {
286
+ return [
287
+ { name: "default", template: TEMPLATE_PRESETS.default, description: "Standard versioned output" },
288
+ { name: "flat", template: TEMPLATE_PRESETS.flat, description: "Flat structure with variant suffix" },
289
+ { name: "locale-first", template: TEMPLATE_PRESETS["locale-first"], description: "Organized by locale for i18n docs" },
290
+ { name: "versioned", template: TEMPLATE_PRESETS.versioned, description: "Date-based versioning" },
291
+ { name: "docs", template: TEMPLATE_PRESETS.docs, description: "Simple docs asset structure" },
292
+ { name: "variant-matrix", template: TEMPLATE_PRESETS["variant-matrix"], description: "Hierarchical by variant dimensions" },
293
+ { name: "static-site", template: TEMPLATE_PRESETS["static-site"], description: "GitHub Pages / static site friendly" },
294
+ { name: "ci", template: TEMPLATE_PRESETS.ci, description: "CI/CD artifact organization" },
295
+ ];
296
+ }
297
+
298
+ /**
299
+ * Ensure output directory exists
300
+ * @param {string} filePath - Full file path
301
+ */
302
+ function ensureOutputDirectory(filePath) {
303
+ const dir = path.dirname(filePath);
304
+ fs.ensureDirSync(dir);
305
+ }
306
+
307
+ module.exports = {
308
+ DEFAULT_OUTPUT_TEMPLATE,
309
+ TEMPLATE_PRESETS,
310
+ parseTemplateVariables,
311
+ buildTemplateContext,
312
+ resolveTemplate,
313
+ resolveOutputPath,
314
+ resolveOutputDirectory,
315
+ validateTemplate,
316
+ getTemplatePresets,
317
+ ensureOutputDirectory,
318
+ };
@@ -0,0 +1,252 @@
1
+ // playwright-runner.js - Generic Playwright runner for executing steps
2
+ const { chromium } = require("playwright");
3
+ const path = require("path");
4
+ const fs = require("fs-extra");
5
+ const { buildLaunchOptions } = require("./ci-detect");
6
+ const { resolveSecretsInObject } = require("./secrets");
7
+
8
+ /**
9
+ * Wait for element with retries and fallbacks
10
+ * @param {Page} page - Playwright page
11
+ * @param {string} selector - CSS selector
12
+ * @param {Object} options - Options
13
+ * @returns {Promise<Locator>}
14
+ */
15
+ async function waitForElement(page, selector, options = {}) {
16
+ const { timeout = 10000, action = "interact" } = options;
17
+
18
+ // First, wait for the page to be stable (no network activity)
19
+ try {
20
+ await page.waitForLoadState("networkidle", { timeout: 5000 });
21
+ } catch (e) {
22
+ // Continue even if networkidle times out
23
+ }
24
+
25
+ // Try the selector directly
26
+ const locator = page.locator(selector);
27
+
28
+ try {
29
+ await locator.first().waitFor({ state: "visible", timeout });
30
+ return locator.first();
31
+ } catch (e) {
32
+ // If selector fails, try some fallback strategies
33
+ console.log(
34
+ ` ⚠ Selector "${selector}" not immediately found, trying fallbacks...`
35
+ );
36
+
37
+ // Strategy 1: Wait a bit longer for dynamic content
38
+ await page.waitForTimeout(1000);
39
+
40
+ try {
41
+ await locator.first().waitFor({ state: "visible", timeout: 3000 });
42
+ return locator.first();
43
+ } catch (e2) {
44
+ // Strategy 2: Try scrolling to make element visible
45
+ try {
46
+ await page.evaluate(() =>
47
+ window.scrollTo(0, document.body.scrollHeight / 2)
48
+ );
49
+ await page.waitForTimeout(500);
50
+ await locator.first().waitFor({ state: "visible", timeout: 2000 });
51
+ return locator.first();
52
+ } catch (e3) {
53
+ // Give up with helpful error
54
+ const count = await locator.count();
55
+ if (count === 0) {
56
+ throw new Error(
57
+ `Element not found: "${selector}" - no matching elements on page`
58
+ );
59
+ } else {
60
+ throw new Error(
61
+ `Element "${selector}" exists (${count} matches) but not visible/clickable`
62
+ );
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Run a sequence of steps with Playwright
71
+ * Based on test/persona-injection/generic_runner.js and test/capturing-target/element_runner.js
72
+ *
73
+ * @param {Object} options - Run options
74
+ * @param {string} options.url - URL to navigate to
75
+ * @param {Array} options.steps - Array of step objects
76
+ * @param {string} options.outputDir - Directory to save output files
77
+ * @param {Object} options.context - Context object for variable resolution
78
+ */
79
+ async function runSteps({ url, steps, outputDir, context }) {
80
+ const browser = await chromium.launch(buildLaunchOptions({ headless: true }));
81
+ const page = await browser.newPage();
82
+
83
+ // Set viewport for consistent rendering
84
+ await page.setViewportSize({ width: 1280, height: 720 });
85
+
86
+ // Set a reasonable default timeout
87
+ page.setDefaultTimeout(20000); // 20 seconds
88
+
89
+ try {
90
+ console.log(` Navigating to ${url}`);
91
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
92
+
93
+ // Wait for initial page load
94
+ await page
95
+ .waitForLoadState("networkidle", { timeout: 10000 })
96
+ .catch(() => {});
97
+
98
+ for (let index = 0; index < steps.length; index++) {
99
+ const step = steps[index];
100
+ console.log(
101
+ ` Executing step ${index + 1}/${steps.length}: ${step.action} ${
102
+ step.selector || ""
103
+ }`
104
+ );
105
+
106
+ // Resolve secrets in all step fields
107
+ const resolvedStep = resolveSecretsInObject(step);
108
+
109
+ try {
110
+ switch (resolvedStep.action) {
111
+ case "goto":
112
+ await page.goto(resolvedStep.url || url, {
113
+ waitUntil: "domcontentloaded",
114
+ });
115
+ await page
116
+ .waitForLoadState("networkidle", { timeout: 10000 })
117
+ .catch(() => {});
118
+ break;
119
+
120
+ case "type":
121
+ case "input": {
122
+ const inputElement = await waitForElement(
123
+ page,
124
+ resolvedStep.selector,
125
+ { action: "fill" }
126
+ );
127
+ await inputElement.fill(resolvedStep.text || "");
128
+ break;
129
+ }
130
+
131
+ case "click": {
132
+ const clickElement = await waitForElement(
133
+ page,
134
+ resolvedStep.selector,
135
+ { action: "click" }
136
+ );
137
+ await clickElement.click();
138
+ // Small delay after click to let page react
139
+ await page.waitForTimeout(300);
140
+ break;
141
+ }
142
+
143
+ case "hover": {
144
+ const hoverElement = await waitForElement(
145
+ page,
146
+ resolvedStep.selector,
147
+ { action: "hover" }
148
+ );
149
+ await hoverElement.hover();
150
+ break;
151
+ }
152
+
153
+ case "waitForSelector":
154
+ await waitForElement(page, resolvedStep.selector);
155
+ break;
156
+
157
+ case "wait":
158
+ await page.waitForTimeout(
159
+ resolvedStep.ms || resolvedStep.duration || 1000
160
+ );
161
+ break;
162
+
163
+ case "screenshot":
164
+ await handleScreenshot(page, resolvedStep, outputDir, index);
165
+ break;
166
+
167
+ default:
168
+ console.warn(` ⚠ Unknown action: ${resolvedStep.action}`);
169
+ }
170
+
171
+ console.log(` ✓ Step ${index + 1} completed`);
172
+ } catch (stepError) {
173
+ // Enhanced error message for selector failures
174
+ const selectorInfo = resolvedStep.selector
175
+ ? ` (selector: "${resolvedStep.selector}")`
176
+ : "";
177
+ console.error(
178
+ ` ❌ Step ${index + 1} failed: ${step.action}${selectorInfo}`
179
+ );
180
+ console.error(` Error: ${stepError.message}`);
181
+
182
+ // Take a screenshot for debugging
183
+ try {
184
+ const debugPath = path.join(
185
+ outputDir || ".",
186
+ `debug-step-${index + 1}-failure.png`
187
+ );
188
+ await page.screenshot({ path: debugPath, fullPage: true });
189
+ console.error(` Debug screenshot saved: ${debugPath}`);
190
+ } catch (ssError) {
191
+ // Ignore screenshot errors
192
+ }
193
+
194
+ // Re-throw to stop execution
195
+ throw new Error(
196
+ `Step ${index + 1} (${step.action}) failed: ${stepError.message}`
197
+ );
198
+ }
199
+ }
200
+
201
+ console.log(` ✔ All ${steps.length} steps completed successfully`);
202
+ } finally {
203
+ await browser.close();
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Handle screenshot step
209
+ * Based on test/capturing-target/element_runner.js
210
+ */
211
+ function resolveScreenshotPath(step, index) {
212
+ if (step.path) {
213
+ return step.path;
214
+ }
215
+
216
+ const baseName = step.captureKey || step.key;
217
+ if (baseName) {
218
+ return path.extname(baseName) ? baseName : `${baseName}.png`;
219
+ }
220
+
221
+ return `step-${(index ?? 0) + 1}.png`;
222
+ }
223
+
224
+ async function handleScreenshot(page, step, outputDir, index) {
225
+ const relativePath = resolveScreenshotPath(step, index);
226
+ const finalPath = path.join(outputDir, relativePath);
227
+ fs.ensureDirSync(path.dirname(finalPath));
228
+
229
+ if (step.selector) {
230
+ // Capture specific element
231
+ const element = await page.locator(step.selector).first();
232
+ await element.screenshot({ path: finalPath });
233
+ console.log(` ✔ Captured element and saved to ${finalPath}`);
234
+ } else {
235
+ // Capture full page
236
+ const screenshotOptions = {
237
+ path: finalPath,
238
+ fullPage: !step.clip,
239
+ };
240
+
241
+ if (step.clip) {
242
+ screenshotOptions.clip = step.clip;
243
+ }
244
+
245
+ await page.screenshot(screenshotOptions);
246
+ console.log(` ✔ Captured full page and saved to ${finalPath}`);
247
+ }
248
+ }
249
+
250
+ module.exports = {
251
+ runSteps,
252
+ };