@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,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
|
+
};
|