real-prototypes-skill 2.0.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/.claude/skills/agent-browser-skill/SKILL.md +252 -0
- package/.claude/skills/real-prototypes-skill/.gitignore +188 -0
- package/.claude/skills/real-prototypes-skill/ACCESSIBILITY.md +668 -0
- package/.claude/skills/real-prototypes-skill/INSTALL.md +259 -0
- package/.claude/skills/real-prototypes-skill/LICENSE +21 -0
- package/.claude/skills/real-prototypes-skill/PUBLISH.md +310 -0
- package/.claude/skills/real-prototypes-skill/QUICKSTART.md +240 -0
- package/.claude/skills/real-prototypes-skill/README.md +442 -0
- package/.claude/skills/real-prototypes-skill/SKILL.md +329 -0
- package/.claude/skills/real-prototypes-skill/capture/capture-engine.js +1153 -0
- package/.claude/skills/real-prototypes-skill/capture/config.schema.json +170 -0
- package/.claude/skills/real-prototypes-skill/cli.js +596 -0
- package/.claude/skills/real-prototypes-skill/docs/TROUBLESHOOTING.md +278 -0
- package/.claude/skills/real-prototypes-skill/docs/schemas/capture-config.md +167 -0
- package/.claude/skills/real-prototypes-skill/docs/schemas/design-tokens.md +183 -0
- package/.claude/skills/real-prototypes-skill/docs/schemas/manifest.md +169 -0
- package/.claude/skills/real-prototypes-skill/examples/CLAUDE.md.example +73 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/CLAUDE.md +136 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/FEATURES.md +222 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/README.md +82 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/design-tokens.json +87 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/screenshots/homepage-viewport.png +0 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/screenshots/prototype-chatbot-final.png +0 -0
- package/.claude/skills/real-prototypes-skill/examples/amazon-chatbot/references/screenshots/prototype-fullpage-v2.png +0 -0
- package/.claude/skills/real-prototypes-skill/references/accessibility-fixes.md +298 -0
- package/.claude/skills/real-prototypes-skill/references/accessibility-report.json +253 -0
- package/.claude/skills/real-prototypes-skill/scripts/CAPTURE-ENHANCEMENTS.md +344 -0
- package/.claude/skills/real-prototypes-skill/scripts/IMPLEMENTATION-SUMMARY.md +517 -0
- package/.claude/skills/real-prototypes-skill/scripts/QUICK-START.md +229 -0
- package/.claude/skills/real-prototypes-skill/scripts/QUICKSTART-layout-analysis.md +148 -0
- package/.claude/skills/real-prototypes-skill/scripts/README-analyze-layout.md +407 -0
- package/.claude/skills/real-prototypes-skill/scripts/analyze-layout.js +880 -0
- package/.claude/skills/real-prototypes-skill/scripts/capture-platform.js +203 -0
- package/.claude/skills/real-prototypes-skill/scripts/comprehensive-capture.js +597 -0
- package/.claude/skills/real-prototypes-skill/scripts/create-manifest.js +338 -0
- package/.claude/skills/real-prototypes-skill/scripts/enterprise-pipeline.js +428 -0
- package/.claude/skills/real-prototypes-skill/scripts/extract-tokens.js +468 -0
- package/.claude/skills/real-prototypes-skill/scripts/full-site-capture.js +738 -0
- package/.claude/skills/real-prototypes-skill/scripts/generate-tailwind-config.js +296 -0
- package/.claude/skills/real-prototypes-skill/scripts/integrate-accessibility.sh +161 -0
- package/.claude/skills/real-prototypes-skill/scripts/manifest-schema.json +302 -0
- package/.claude/skills/real-prototypes-skill/scripts/setup-prototype.sh +167 -0
- package/.claude/skills/real-prototypes-skill/scripts/test-analyze-layout.js +338 -0
- package/.claude/skills/real-prototypes-skill/scripts/test-validation.js +307 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-accessibility.js +598 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-manifest.js +499 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-output.js +361 -0
- package/.claude/skills/real-prototypes-skill/scripts/validate-prerequisites.js +319 -0
- package/.claude/skills/real-prototypes-skill/scripts/verify-layout-analysis.sh +77 -0
- package/.claude/skills/real-prototypes-skill/templates/dashboard-widget.tsx.template +91 -0
- package/.claude/skills/real-prototypes-skill/templates/data-table.tsx.template +193 -0
- package/.claude/skills/real-prototypes-skill/templates/form-section.tsx.template +250 -0
- package/.claude/skills/real-prototypes-skill/templates/modal-dialog.tsx.template +239 -0
- package/.claude/skills/real-prototypes-skill/templates/nav-item.tsx.template +265 -0
- package/.claude/skills/real-prototypes-skill/validation/validation-engine.js +559 -0
- package/.env.example +74 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/bin/cli.js +319 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform Capture Engine
|
|
5
|
+
*
|
|
6
|
+
* A comprehensive, enterprise-grade web platform capture system.
|
|
7
|
+
* Automatically discovers and captures all pages, states, and interactions.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node capture-engine.js --config ./capture-config.json
|
|
11
|
+
* node capture-engine.js --url https://app.example.com --email user@test.com --password secret
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
class CaptureEngine {
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.config = this.normalizeConfig(config);
|
|
21
|
+
this.capturedPages = new Map();
|
|
22
|
+
this.discoveredUrls = new Set();
|
|
23
|
+
this.visitedUrls = new Set();
|
|
24
|
+
this.interactions = [];
|
|
25
|
+
this.errors = [];
|
|
26
|
+
this.warnings = [];
|
|
27
|
+
this.stats = {
|
|
28
|
+
pagesDiscovered: 0,
|
|
29
|
+
pagesCaptured: 0,
|
|
30
|
+
screenshotsTaken: 0,
|
|
31
|
+
htmlCaptured: 0,
|
|
32
|
+
interactionsPerformed: 0,
|
|
33
|
+
errorsEncountered: 0
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize config to handle both old and new field naming conventions
|
|
39
|
+
* Supports backwards compatibility while standardizing internally
|
|
40
|
+
*/
|
|
41
|
+
normalizeConfig(config) {
|
|
42
|
+
const normalized = JSON.parse(JSON.stringify(config)); // Deep clone
|
|
43
|
+
|
|
44
|
+
// Normalize capture section
|
|
45
|
+
if (normalized.capture) {
|
|
46
|
+
// Support both 'manualPages' and 'include' (prefer manualPages, fallback to include)
|
|
47
|
+
if (normalized.capture.manualPages && !normalized.capture.include) {
|
|
48
|
+
normalized.capture.include = normalized.capture.manualPages;
|
|
49
|
+
}
|
|
50
|
+
// Also support the reverse for users who use 'include'
|
|
51
|
+
if (normalized.capture.include && !normalized.capture.manualPages) {
|
|
52
|
+
normalized.capture.manualPages = normalized.capture.include;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Normalize auth credentials section
|
|
57
|
+
if (normalized.auth?.credentials) {
|
|
58
|
+
const creds = normalized.auth.credentials;
|
|
59
|
+
|
|
60
|
+
// Support both 'emailSelector' and 'emailField'
|
|
61
|
+
if (creds.emailSelector && !creds.emailField) {
|
|
62
|
+
// emailSelector is CSS, keep it as selector
|
|
63
|
+
}
|
|
64
|
+
if (creds.emailField && !creds.emailSelector) {
|
|
65
|
+
// emailField might be a label name or a selector
|
|
66
|
+
// If it looks like a CSS selector, treat it as such
|
|
67
|
+
if (creds.emailField.startsWith('#') || creds.emailField.startsWith('.') || creds.emailField.includes('[')) {
|
|
68
|
+
creds.emailSelector = creds.emailField;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Support both 'passwordSelector' and 'passwordField'
|
|
73
|
+
if (creds.passwordSelector && !creds.passwordField) {
|
|
74
|
+
// passwordSelector is CSS, keep it
|
|
75
|
+
}
|
|
76
|
+
if (creds.passwordField && !creds.passwordSelector) {
|
|
77
|
+
if (creds.passwordField.startsWith('#') || creds.passwordField.startsWith('.') || creds.passwordField.includes('[')) {
|
|
78
|
+
creds.passwordSelector = creds.passwordField;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Support both 'submitSelector' and 'submitButton'
|
|
83
|
+
if (creds.submitSelector && !creds.submitButton) {
|
|
84
|
+
// submitSelector is CSS
|
|
85
|
+
}
|
|
86
|
+
if (creds.submitButton && !creds.submitSelector) {
|
|
87
|
+
if (creds.submitButton.startsWith('#') || creds.submitButton.startsWith('.') || creds.submitButton.includes('[')) {
|
|
88
|
+
creds.submitSelector = creds.submitButton;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate and warn about unknown fields
|
|
94
|
+
this.validateConfigFields(normalized);
|
|
95
|
+
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate config and warn about unknown/deprecated fields
|
|
101
|
+
*/
|
|
102
|
+
validateConfigFields(config) {
|
|
103
|
+
const knownFields = {
|
|
104
|
+
platform: ['name', 'baseUrl'],
|
|
105
|
+
auth: ['type', 'loginUrl', 'credentials', 'successUrl', 'waitAfterLogin'],
|
|
106
|
+
'auth.credentials': ['email', 'password', 'emailField', 'emailSelector', 'passwordField', 'passwordSelector', 'submitButton', 'submitSelector'],
|
|
107
|
+
capture: ['mode', 'maxPages', 'maxDepth', 'viewports', 'interactions', 'include', 'manualPages', 'exclude', 'waitAfterLoad', 'waitAfterInteraction'],
|
|
108
|
+
output: ['directory', 'screenshots', 'html', 'designTokens'],
|
|
109
|
+
validation: ['minPages', 'minColors', 'requireDetailPages', 'requireAllTabs']
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const warnings = [];
|
|
113
|
+
|
|
114
|
+
// Check top-level unknown fields
|
|
115
|
+
const topLevelKnown = ['platform', 'auth', 'capture', 'output', 'validation'];
|
|
116
|
+
Object.keys(config).forEach(key => {
|
|
117
|
+
if (!topLevelKnown.includes(key)) {
|
|
118
|
+
warnings.push(`Unknown config field: '${key}' - this will be ignored`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Check nested fields
|
|
123
|
+
Object.entries(knownFields).forEach(([section, fields]) => {
|
|
124
|
+
const sectionParts = section.split('.');
|
|
125
|
+
let obj = config;
|
|
126
|
+
for (const part of sectionParts) {
|
|
127
|
+
obj = obj?.[part];
|
|
128
|
+
}
|
|
129
|
+
if (obj && typeof obj === 'object') {
|
|
130
|
+
Object.keys(obj).forEach(key => {
|
|
131
|
+
if (!fields.includes(key) && typeof obj[key] !== 'object') {
|
|
132
|
+
warnings.push(`Unknown config field in ${section}: '${key}' - this will be ignored`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (warnings.length > 0) {
|
|
139
|
+
console.log('\n⚠ Configuration warnings:');
|
|
140
|
+
warnings.forEach(w => console.log(` - ${w}`));
|
|
141
|
+
console.log('');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
log(message, type = 'info') {
|
|
146
|
+
const colors = {
|
|
147
|
+
info: '\x1b[36m', // cyan
|
|
148
|
+
success: '\x1b[32m', // green
|
|
149
|
+
warning: '\x1b[33m', // yellow
|
|
150
|
+
error: '\x1b[31m', // red
|
|
151
|
+
step: '\x1b[90m', // gray
|
|
152
|
+
progress: '\x1b[35m', // magenta
|
|
153
|
+
reset: '\x1b[0m'
|
|
154
|
+
};
|
|
155
|
+
const prefix = {
|
|
156
|
+
info: '→',
|
|
157
|
+
success: '✓',
|
|
158
|
+
warning: '⚠',
|
|
159
|
+
error: '✗',
|
|
160
|
+
step: '•',
|
|
161
|
+
progress: '◐'
|
|
162
|
+
}[type] || '→';
|
|
163
|
+
console.log(`${colors[type] || ''}${prefix} ${message}${colors.reset}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Display progress for multi-item operations
|
|
168
|
+
*/
|
|
169
|
+
logProgress(current, total, message) {
|
|
170
|
+
const percent = Math.round((current / total) * 100);
|
|
171
|
+
const bar = this.createProgressBar(percent);
|
|
172
|
+
process.stdout.write(`\r\x1b[35m${bar}\x1b[0m ${current}/${total} ${message} `);
|
|
173
|
+
if (current === total) {
|
|
174
|
+
console.log(''); // New line when complete
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
createProgressBar(percent, width = 20) {
|
|
179
|
+
const filled = Math.round((percent / 100) * width);
|
|
180
|
+
const empty = width - filled;
|
|
181
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${percent}%`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Display spinner for ongoing operations
|
|
186
|
+
*/
|
|
187
|
+
showSpinner(message) {
|
|
188
|
+
const frames = ['◐', '◓', '◑', '◒'];
|
|
189
|
+
let i = 0;
|
|
190
|
+
this._spinnerInterval = setInterval(() => {
|
|
191
|
+
process.stdout.write(`\r\x1b[35m${frames[i]} ${message}...\x1b[0m `);
|
|
192
|
+
i = (i + 1) % frames.length;
|
|
193
|
+
}, 100);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
hideSpinner(message, success = true) {
|
|
197
|
+
if (this._spinnerInterval) {
|
|
198
|
+
clearInterval(this._spinnerInterval);
|
|
199
|
+
this._spinnerInterval = null;
|
|
200
|
+
}
|
|
201
|
+
const icon = success ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
202
|
+
console.log(`\r${icon} ${message} `);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
exec(command) {
|
|
206
|
+
try {
|
|
207
|
+
const result = execSync(command, { encoding: 'utf-8', timeout: 60000 });
|
|
208
|
+
return { success: true, output: result.trim() };
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return { success: false, error: error.message };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
browser(action) {
|
|
215
|
+
return this.exec(`agent-browser ${action}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async run() {
|
|
219
|
+
this.log('Starting Platform Capture Engine', 'info');
|
|
220
|
+
this.log(`Platform: ${this.config.platform.name}`, 'step');
|
|
221
|
+
this.log(`Base URL: ${this.config.platform.baseUrl}`, 'step');
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// Phase 1: Setup
|
|
225
|
+
await this.setup();
|
|
226
|
+
|
|
227
|
+
// Phase 2: Authentication
|
|
228
|
+
if (this.config.auth.type !== 'none') {
|
|
229
|
+
await this.authenticate();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Phase 3: Discovery
|
|
233
|
+
await this.discoverPages();
|
|
234
|
+
|
|
235
|
+
// Phase 4: Capture
|
|
236
|
+
await this.captureAllPages();
|
|
237
|
+
|
|
238
|
+
// Phase 5: Extract Design Tokens
|
|
239
|
+
await this.extractDesignTokens();
|
|
240
|
+
|
|
241
|
+
// Phase 6: Validation
|
|
242
|
+
const validationResult = await this.validate();
|
|
243
|
+
|
|
244
|
+
// Phase 7: Generate Manifest
|
|
245
|
+
await this.generateManifest();
|
|
246
|
+
|
|
247
|
+
// Phase 8: Report
|
|
248
|
+
this.generateReport(validationResult);
|
|
249
|
+
|
|
250
|
+
return { success: validationResult.passed, stats: this.stats };
|
|
251
|
+
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.log(`Fatal error: ${error.message}`, 'error');
|
|
254
|
+
this.errors.push({ phase: 'execution', error: error.message });
|
|
255
|
+
return { success: false, error: error.message };
|
|
256
|
+
} finally {
|
|
257
|
+
this.browser('close');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async setup() {
|
|
262
|
+
this.log('Setting up capture environment...', 'info');
|
|
263
|
+
|
|
264
|
+
const outputDir = this.config.output?.directory || './references';
|
|
265
|
+
const dirs = [
|
|
266
|
+
outputDir,
|
|
267
|
+
path.join(outputDir, 'screenshots'),
|
|
268
|
+
path.join(outputDir, 'html'),
|
|
269
|
+
path.join(outputDir, 'viewports')
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
dirs.forEach(dir => {
|
|
273
|
+
if (!fs.existsSync(dir)) {
|
|
274
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
275
|
+
this.log(`Created directory: ${dir}`, 'step');
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Set viewport
|
|
280
|
+
const defaultViewport = this.config.capture?.viewports?.[0] || { width: 1920, height: 1080 };
|
|
281
|
+
this.browser(`set viewport ${defaultViewport.width} ${defaultViewport.height}`);
|
|
282
|
+
|
|
283
|
+
this.log('Setup complete', 'success');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async authenticate() {
|
|
287
|
+
const auth = this.config.auth;
|
|
288
|
+
const loginUrl = `${this.config.platform.baseUrl}${auth.loginUrl || '/login'}`;
|
|
289
|
+
|
|
290
|
+
this.showSpinner('Navigating to login page');
|
|
291
|
+
this.browser(`open ${loginUrl}`);
|
|
292
|
+
this.browser(`wait 2000`);
|
|
293
|
+
this.hideSpinner('Loaded login page', true);
|
|
294
|
+
|
|
295
|
+
if (auth.type === 'form') {
|
|
296
|
+
// Get credentials from environment or config
|
|
297
|
+
const email = process.env.PLATFORM_EMAIL || auth.credentials?.email;
|
|
298
|
+
const password = process.env.PLATFORM_PASSWORD || auth.credentials?.password;
|
|
299
|
+
|
|
300
|
+
if (!email || !password) {
|
|
301
|
+
throw new Error('Missing credentials. Set PLATFORM_EMAIL and PLATFORM_PASSWORD environment variables.');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Get snapshot of interactive elements to find form fields
|
|
305
|
+
const snapshot = this.browser('snapshot -i');
|
|
306
|
+
if (!snapshot.success) {
|
|
307
|
+
throw new Error('Could not get page snapshot for login form');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Parse snapshot to find form elements
|
|
311
|
+
const formElements = this.parseLoginForm(snapshot.output, auth.credentials);
|
|
312
|
+
|
|
313
|
+
if (!formElements.emailRef && !formElements.emailSelector) {
|
|
314
|
+
this.log('Login form analysis:', 'warning');
|
|
315
|
+
this.log(snapshot.output.substring(0, 1000), 'info');
|
|
316
|
+
throw new Error(
|
|
317
|
+
'Could not find email/username field on login page.\n\n' +
|
|
318
|
+
'Troubleshooting:\n' +
|
|
319
|
+
'1. Check the login URL is correct: ' + loginUrl + '\n' +
|
|
320
|
+
'2. Specify selectors in config:\n' +
|
|
321
|
+
' auth.credentials.emailSelector = "#email"\n' +
|
|
322
|
+
' auth.credentials.passwordSelector = "#password"\n' +
|
|
323
|
+
' auth.credentials.submitSelector = "button[type=submit]"\n' +
|
|
324
|
+
'3. Run with --headed flag to see the browser'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Fill email field (prefer ref, fallback to selector)
|
|
329
|
+
if (formElements.emailRef) {
|
|
330
|
+
this.browser(`fill ${formElements.emailRef} "${email}"`);
|
|
331
|
+
} else if (formElements.emailSelector) {
|
|
332
|
+
this.browser(`fill "${formElements.emailSelector}" "${email}"`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Fill password field
|
|
336
|
+
if (formElements.passwordRef) {
|
|
337
|
+
this.browser(`fill ${formElements.passwordRef} "${password}"`);
|
|
338
|
+
} else if (formElements.passwordSelector) {
|
|
339
|
+
this.browser(`fill "${formElements.passwordSelector}" "${password}"`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Click submit button
|
|
343
|
+
this.showSpinner('Logging in');
|
|
344
|
+
if (formElements.submitRef) {
|
|
345
|
+
this.browser(`click ${formElements.submitRef}`);
|
|
346
|
+
} else if (formElements.submitSelector) {
|
|
347
|
+
this.browser(`click "${formElements.submitSelector}"`);
|
|
348
|
+
} else {
|
|
349
|
+
// Fallback: try pressing Enter
|
|
350
|
+
this.browser('press Enter');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.browser(`wait 3000`);
|
|
354
|
+
this.hideSpinner('Login submitted', true);
|
|
355
|
+
|
|
356
|
+
// Verify login success
|
|
357
|
+
const currentUrl = this.browser('get url').output;
|
|
358
|
+
if (currentUrl && currentUrl.includes('login')) {
|
|
359
|
+
// Get page state for debugging
|
|
360
|
+
const pageSnapshot = this.browser('snapshot -i').output || '';
|
|
361
|
+
const errorText = this.extractErrorMessages(pageSnapshot);
|
|
362
|
+
|
|
363
|
+
throw new Error(
|
|
364
|
+
'Authentication failed - still on login page.\n\n' +
|
|
365
|
+
'Possible causes:\n' +
|
|
366
|
+
'1. Incorrect credentials\n' +
|
|
367
|
+
'2. CAPTCHA or 2FA required\n' +
|
|
368
|
+
'3. Account locked or needs verification\n' +
|
|
369
|
+
(errorText ? '\nPage errors found: ' + errorText + '\n' : '') +
|
|
370
|
+
'\nCurrent URL: ' + currentUrl + '\n' +
|
|
371
|
+
'\nTry running with --headed to see the browser window.'
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
this.log('Authentication successful', 'success');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Parse login form snapshot to find email, password, and submit elements
|
|
381
|
+
* Uses refs from snapshot for unambiguous element targeting
|
|
382
|
+
*/
|
|
383
|
+
parseLoginForm(snapshot, credentials = {}) {
|
|
384
|
+
const elements = {
|
|
385
|
+
emailRef: null,
|
|
386
|
+
emailSelector: credentials?.emailSelector || null,
|
|
387
|
+
passwordRef: null,
|
|
388
|
+
passwordSelector: credentials?.passwordSelector || null,
|
|
389
|
+
submitRef: null,
|
|
390
|
+
submitSelector: credentials?.submitSelector || null
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// If explicit selectors provided, use those
|
|
394
|
+
if (elements.emailSelector && elements.passwordSelector) {
|
|
395
|
+
return elements;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const lines = snapshot.split('\n');
|
|
399
|
+
|
|
400
|
+
// Keywords to identify email fields
|
|
401
|
+
const emailKeywords = ['email', 'username', 'user', 'login', 'account', 'e-mail'];
|
|
402
|
+
const passwordKeywords = ['password', 'pass', 'pwd', 'secret'];
|
|
403
|
+
const submitKeywords = ['sign in', 'log in', 'login', 'submit', 'continue', 'next'];
|
|
404
|
+
|
|
405
|
+
for (const line of lines) {
|
|
406
|
+
const lowerLine = line.toLowerCase();
|
|
407
|
+
const refMatch = line.match(/\[ref=(\w+)\]/);
|
|
408
|
+
const ref = refMatch ? `@${refMatch[1]}` : null;
|
|
409
|
+
|
|
410
|
+
// Look for email/username field
|
|
411
|
+
if (!elements.emailRef && ref) {
|
|
412
|
+
if (lowerLine.includes('textbox') || lowerLine.includes('input')) {
|
|
413
|
+
// Check for email type or email-related text
|
|
414
|
+
if (lowerLine.includes('type="email"') ||
|
|
415
|
+
emailKeywords.some(kw => lowerLine.includes(kw))) {
|
|
416
|
+
elements.emailRef = ref;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Look for password field
|
|
423
|
+
if (!elements.passwordRef && ref) {
|
|
424
|
+
if (lowerLine.includes('type="password"') ||
|
|
425
|
+
(lowerLine.includes('textbox') && passwordKeywords.some(kw => lowerLine.includes(kw)))) {
|
|
426
|
+
elements.passwordRef = ref;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Look for submit button
|
|
432
|
+
if (!elements.submitRef && ref) {
|
|
433
|
+
if (lowerLine.includes('button') && !lowerLine.includes('[disabled]')) {
|
|
434
|
+
if (submitKeywords.some(kw => lowerLine.includes(kw))) {
|
|
435
|
+
elements.submitRef = ref;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Fallback: if we found a password field but no email, find the first textbox before it
|
|
442
|
+
if (elements.passwordRef && !elements.emailRef) {
|
|
443
|
+
let foundPassword = false;
|
|
444
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
445
|
+
const line = lines[i];
|
|
446
|
+
const lowerLine = line.toLowerCase();
|
|
447
|
+
const refMatch = line.match(/\[ref=(\w+)\]/);
|
|
448
|
+
const ref = refMatch ? `@${refMatch[1]}` : null;
|
|
449
|
+
|
|
450
|
+
if (ref === elements.passwordRef) {
|
|
451
|
+
foundPassword = true;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (foundPassword && ref &&
|
|
456
|
+
(lowerLine.includes('textbox') || lowerLine.includes('input')) &&
|
|
457
|
+
!lowerLine.includes('type="password"')) {
|
|
458
|
+
elements.emailRef = ref;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return elements;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Extract error messages from page snapshot
|
|
469
|
+
*/
|
|
470
|
+
extractErrorMessages(snapshot) {
|
|
471
|
+
const errorPatterns = [
|
|
472
|
+
/error[:\s]+"([^"]+)"/gi,
|
|
473
|
+
/invalid[:\s]+"([^"]+)"/gi,
|
|
474
|
+
/failed[:\s]+"([^"]+)"/gi,
|
|
475
|
+
/alert[:\s]+"([^"]+)"/gi
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
const errors = [];
|
|
479
|
+
for (const pattern of errorPatterns) {
|
|
480
|
+
const matches = snapshot.match(pattern);
|
|
481
|
+
if (matches) {
|
|
482
|
+
errors.push(...matches);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return errors.slice(0, 3).join(', ');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async discoverPages() {
|
|
490
|
+
const mode = this.config.capture?.mode || 'auto';
|
|
491
|
+
const maxPages = this.config.capture?.maxPages || 100;
|
|
492
|
+
const maxDepth = this.config.capture?.maxDepth || 5;
|
|
493
|
+
|
|
494
|
+
this.showSpinner(`Discovering pages (mode: ${mode}, max: ${maxPages})`);
|
|
495
|
+
console.log(''); // New line for spinner
|
|
496
|
+
|
|
497
|
+
// Start from current page after login
|
|
498
|
+
const startUrl = this.browser('get url').output;
|
|
499
|
+
this.discoveredUrls.add(startUrl);
|
|
500
|
+
|
|
501
|
+
if (mode === 'auto' || mode === 'hybrid') {
|
|
502
|
+
await this.autoDiscover(startUrl, 0, maxDepth, maxPages);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (mode === 'manual' || mode === 'hybrid') {
|
|
506
|
+
const manualPages = this.config.capture?.include || [];
|
|
507
|
+
manualPages.forEach(pattern => {
|
|
508
|
+
const url = `${this.config.platform.baseUrl}${pattern}`;
|
|
509
|
+
this.discoveredUrls.add(url);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
this.stats.pagesDiscovered = this.discoveredUrls.size;
|
|
514
|
+
this.hideSpinner(`Discovered ${this.discoveredUrls.size} pages`, true);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async autoDiscover(url, depth, maxDepth, maxPages) {
|
|
518
|
+
if (depth > maxDepth || this.discoveredUrls.size >= maxPages) return;
|
|
519
|
+
if (this.visitedUrls.has(url)) return;
|
|
520
|
+
|
|
521
|
+
this.visitedUrls.add(url);
|
|
522
|
+
|
|
523
|
+
// Navigate to page
|
|
524
|
+
this.browser(`open ${url}`);
|
|
525
|
+
this.browser(`wait ${this.config.capture?.waitAfterLoad || 2000}`);
|
|
526
|
+
|
|
527
|
+
// Get all links on the page
|
|
528
|
+
const snapshot = this.browser('snapshot').output;
|
|
529
|
+
const links = this.extractLinks(snapshot);
|
|
530
|
+
|
|
531
|
+
for (const link of links) {
|
|
532
|
+
if (this.shouldCapture(link) && !this.discoveredUrls.has(link)) {
|
|
533
|
+
this.discoveredUrls.add(link);
|
|
534
|
+
this.log(`Found: ${link}`, 'step');
|
|
535
|
+
|
|
536
|
+
if (this.discoveredUrls.size < maxPages) {
|
|
537
|
+
await this.autoDiscover(link, depth + 1, maxDepth, maxPages);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
extractLinks(snapshot) {
|
|
544
|
+
const links = [];
|
|
545
|
+
const baseUrl = this.config.platform.baseUrl;
|
|
546
|
+
|
|
547
|
+
// Parse snapshot for links (simplified - real implementation would parse properly)
|
|
548
|
+
const linkPatterns = snapshot.match(/href="([^"]+)"/g) || [];
|
|
549
|
+
const buttonLinks = snapshot.match(/link "([^"]+)"/g) || [];
|
|
550
|
+
|
|
551
|
+
[...linkPatterns, ...buttonLinks].forEach(match => {
|
|
552
|
+
const href = match.replace(/href="|link "|"/g, '');
|
|
553
|
+
if (href.startsWith('/')) {
|
|
554
|
+
links.push(`${baseUrl}${href}`);
|
|
555
|
+
} else if (href.startsWith(baseUrl)) {
|
|
556
|
+
links.push(href);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
return [...new Set(links)];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
shouldCapture(url) {
|
|
564
|
+
const exclude = this.config.capture?.exclude || ['/logout', '/signout', '/delete'];
|
|
565
|
+
const baseUrl = this.config.platform.baseUrl;
|
|
566
|
+
|
|
567
|
+
// Must be same domain
|
|
568
|
+
if (!url.startsWith(baseUrl)) return false;
|
|
569
|
+
|
|
570
|
+
// Check exclusions
|
|
571
|
+
for (const pattern of exclude) {
|
|
572
|
+
if (url.includes(pattern)) return false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async captureAllPages() {
|
|
579
|
+
this.log('Capturing pages...', 'info');
|
|
580
|
+
|
|
581
|
+
const outputDir = this.config.output?.directory || './references';
|
|
582
|
+
const viewports = this.config.capture?.viewports || [{ name: 'desktop', width: 1920, height: 1080 }];
|
|
583
|
+
const totalPages = this.discoveredUrls.size;
|
|
584
|
+
let currentPage = 0;
|
|
585
|
+
|
|
586
|
+
console.log(` Viewports: ${viewports.map(v => v.name).join(', ')}`);
|
|
587
|
+
console.log(` Total pages to capture: ${totalPages}\n`);
|
|
588
|
+
|
|
589
|
+
for (const url of this.discoveredUrls) {
|
|
590
|
+
currentPage++;
|
|
591
|
+
const pageName = this.urlToPageName(url);
|
|
592
|
+
this.logProgress(currentPage, totalPages, pageName);
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
await this.capturePage(url, outputDir, viewports);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
this.log(`Failed to capture ${url}: ${error.message}`, 'error');
|
|
598
|
+
this.errors.push({ url, error: error.message });
|
|
599
|
+
this.stats.errorsEncountered++;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
console.log('');
|
|
604
|
+
this.log(`Captured ${this.stats.pagesCaptured} pages, ${this.stats.screenshotsTaken} screenshots`, 'success');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async capturePage(url, outputDir, viewports) {
|
|
608
|
+
this.browser(`open ${url}`);
|
|
609
|
+
this.browser(`wait ${this.config.capture?.waitAfterLoad || 2000}`);
|
|
610
|
+
|
|
611
|
+
const pageName = this.urlToPageName(url);
|
|
612
|
+
const pageData = {
|
|
613
|
+
name: pageName,
|
|
614
|
+
url: url.replace(this.config.platform.baseUrl, ''),
|
|
615
|
+
captures: [],
|
|
616
|
+
tabs: [],
|
|
617
|
+
interactions: []
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Capture for each viewport
|
|
621
|
+
for (const viewport of viewports) {
|
|
622
|
+
this.browser(`set viewport ${viewport.width} ${viewport.height}`);
|
|
623
|
+
this.browser(`wait 500`);
|
|
624
|
+
|
|
625
|
+
const screenshotPath = path.join(outputDir, 'screenshots', `${pageName}-${viewport.name}.png`);
|
|
626
|
+
this.browser(`screenshot ${screenshotPath}`);
|
|
627
|
+
this.stats.screenshotsTaken++;
|
|
628
|
+
|
|
629
|
+
pageData.captures.push({
|
|
630
|
+
viewport: viewport.name,
|
|
631
|
+
screenshot: `screenshots/${pageName}-${viewport.name}.png`
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Reset to desktop for HTML capture
|
|
636
|
+
this.browser(`set viewport ${viewports[0].width} ${viewports[0].height}`);
|
|
637
|
+
|
|
638
|
+
// Capture HTML
|
|
639
|
+
if (this.config.output?.html !== false) {
|
|
640
|
+
const htmlPath = path.join(outputDir, 'html', `${pageName}.html`);
|
|
641
|
+
const htmlContent = this.browser('eval "document.documentElement.outerHTML"').output;
|
|
642
|
+
if (htmlContent) {
|
|
643
|
+
fs.writeFileSync(htmlPath, htmlContent);
|
|
644
|
+
pageData.html = `html/${pageName}.html`;
|
|
645
|
+
this.stats.htmlCaptured++;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Capture interactions (tabs, dropdowns, etc.)
|
|
650
|
+
if (this.config.capture?.interactions?.clickTabs !== false) {
|
|
651
|
+
await this.captureTabStates(pageName, outputDir, pageData);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (this.config.capture?.interactions?.clickDropdowns !== false) {
|
|
655
|
+
await this.captureDropdownStates(pageName, outputDir, pageData);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (this.config.capture?.interactions?.clickTableRows !== false) {
|
|
659
|
+
await this.captureDetailPages(pageName, outputDir, pageData);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
this.capturedPages.set(url, pageData);
|
|
663
|
+
this.stats.pagesCaptured++;
|
|
664
|
+
this.log(`Captured: ${pageName}`, 'success');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async captureTabStates(pageName, outputDir, pageData) {
|
|
668
|
+
const snapshot = this.browser('snapshot -i').output;
|
|
669
|
+
const tabs = this.findTabs(snapshot);
|
|
670
|
+
|
|
671
|
+
for (const tab of tabs) {
|
|
672
|
+
try {
|
|
673
|
+
this.browser(`click ${tab.ref}`);
|
|
674
|
+
this.browser(`wait ${this.config.capture?.waitAfterInteraction || 1000}`);
|
|
675
|
+
|
|
676
|
+
const tabName = this.sanitizeName(tab.name);
|
|
677
|
+
const screenshotPath = path.join(outputDir, 'screenshots', `${pageName}-tab-${tabName}.png`);
|
|
678
|
+
this.browser(`screenshot ${screenshotPath}`);
|
|
679
|
+
this.stats.screenshotsTaken++;
|
|
680
|
+
this.stats.interactionsPerformed++;
|
|
681
|
+
|
|
682
|
+
pageData.tabs.push({
|
|
683
|
+
name: tab.name,
|
|
684
|
+
screenshot: `screenshots/${pageName}-tab-${tabName}.png`
|
|
685
|
+
});
|
|
686
|
+
} catch (error) {
|
|
687
|
+
this.warnings.push(`Failed to capture tab ${tab.name}: ${error.message}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async captureDropdownStates(pageName, outputDir, pageData) {
|
|
693
|
+
const snapshot = this.browser('snapshot -i').output;
|
|
694
|
+
const dropdowns = this.findDropdowns(snapshot);
|
|
695
|
+
|
|
696
|
+
for (const dropdown of dropdowns) {
|
|
697
|
+
try {
|
|
698
|
+
this.browser(`click ${dropdown.ref}`);
|
|
699
|
+
this.browser(`wait 500`);
|
|
700
|
+
|
|
701
|
+
const dropdownName = this.sanitizeName(dropdown.name);
|
|
702
|
+
const screenshotPath = path.join(outputDir, 'screenshots', `${pageName}-dropdown-${dropdownName}.png`);
|
|
703
|
+
this.browser(`screenshot ${screenshotPath}`);
|
|
704
|
+
this.stats.screenshotsTaken++;
|
|
705
|
+
this.stats.interactionsPerformed++;
|
|
706
|
+
|
|
707
|
+
pageData.interactions.push({
|
|
708
|
+
type: 'dropdown',
|
|
709
|
+
name: dropdown.name,
|
|
710
|
+
screenshot: `screenshots/${pageName}-dropdown-${dropdownName}.png`
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// Close dropdown
|
|
714
|
+
this.browser('press Escape');
|
|
715
|
+
} catch (error) {
|
|
716
|
+
this.warnings.push(`Failed to capture dropdown ${dropdown.name}: ${error.message}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async captureDetailPages(pageName, outputDir, pageData) {
|
|
722
|
+
// Only for list pages
|
|
723
|
+
if (!pageName.includes('list') && !pageName.includes('accounts') && !pageName.includes('contacts')) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const snapshot = this.browser('snapshot -i').output;
|
|
728
|
+
const tableRows = this.findClickableTableRows(snapshot);
|
|
729
|
+
|
|
730
|
+
// Capture first row as example detail page
|
|
731
|
+
if (tableRows.length > 0) {
|
|
732
|
+
try {
|
|
733
|
+
const firstRow = tableRows[0];
|
|
734
|
+
this.browser(`click ${firstRow.ref}`);
|
|
735
|
+
this.browser(`wait 2000`);
|
|
736
|
+
|
|
737
|
+
const newUrl = this.browser('get url').output;
|
|
738
|
+
if (!this.discoveredUrls.has(newUrl)) {
|
|
739
|
+
this.discoveredUrls.add(newUrl);
|
|
740
|
+
this.log(`Discovered detail page: ${newUrl}`, 'step');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Go back
|
|
744
|
+
this.browser('back');
|
|
745
|
+
this.browser(`wait 1000`);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
this.warnings.push(`Failed to capture detail page: ${error.message}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
findTabs(snapshot) {
|
|
753
|
+
const tabs = [];
|
|
754
|
+
const lines = snapshot.split('\n');
|
|
755
|
+
|
|
756
|
+
for (const line of lines) {
|
|
757
|
+
if (line.includes('tab ') || line.includes('button') && (
|
|
758
|
+
line.toLowerCase().includes('overview') ||
|
|
759
|
+
line.toLowerCase().includes('activity') ||
|
|
760
|
+
line.toLowerCase().includes('settings') ||
|
|
761
|
+
line.toLowerCase().includes('details')
|
|
762
|
+
)) {
|
|
763
|
+
const refMatch = line.match(/\[ref=(\w+)\]/);
|
|
764
|
+
const nameMatch = line.match(/"([^"]+)"/);
|
|
765
|
+
if (refMatch && nameMatch) {
|
|
766
|
+
tabs.push({ ref: `@${refMatch[1]}`, name: nameMatch[1] });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return tabs;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
findDropdowns(snapshot) {
|
|
774
|
+
const dropdowns = [];
|
|
775
|
+
const lines = snapshot.split('\n');
|
|
776
|
+
|
|
777
|
+
for (const line of lines) {
|
|
778
|
+
if (line.includes('combobox') || (line.includes('button') && line.toLowerCase().includes('action'))) {
|
|
779
|
+
const refMatch = line.match(/\[ref=(\w+)\]/);
|
|
780
|
+
const nameMatch = line.match(/"([^"]+)"/);
|
|
781
|
+
if (refMatch) {
|
|
782
|
+
dropdowns.push({
|
|
783
|
+
ref: `@${refMatch[1]}`,
|
|
784
|
+
name: nameMatch ? nameMatch[1] : 'dropdown'
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return dropdowns;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
findClickableTableRows(snapshot) {
|
|
793
|
+
const rows = [];
|
|
794
|
+
const lines = snapshot.split('\n');
|
|
795
|
+
|
|
796
|
+
for (const line of lines) {
|
|
797
|
+
if (line.includes('button') && !line.includes('[disabled]')) {
|
|
798
|
+
const refMatch = line.match(/\[ref=(\w+)\]/);
|
|
799
|
+
const nameMatch = line.match(/"([^"]+)"/);
|
|
800
|
+
if (refMatch && nameMatch && nameMatch[1].length > 2) {
|
|
801
|
+
rows.push({ ref: `@${refMatch[1]}`, name: nameMatch[1] });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return rows.slice(0, 5); // Limit to first 5
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
urlToPageName(url) {
|
|
809
|
+
const path = url.replace(this.config.platform.baseUrl, '');
|
|
810
|
+
return this.sanitizeName(path) || 'home';
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
sanitizeName(str) {
|
|
814
|
+
return str
|
|
815
|
+
.toLowerCase()
|
|
816
|
+
.replace(/^\//, '')
|
|
817
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
818
|
+
.replace(/^-|-$/g, '')
|
|
819
|
+
.substring(0, 50);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async extractDesignTokens() {
|
|
823
|
+
this.log('Extracting design tokens...', 'info');
|
|
824
|
+
|
|
825
|
+
const outputDir = this.config.output?.directory || './references';
|
|
826
|
+
const htmlDir = path.join(outputDir, 'html');
|
|
827
|
+
|
|
828
|
+
if (!fs.existsSync(htmlDir)) {
|
|
829
|
+
this.warnings.push('No HTML files to extract tokens from');
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const colors = new Map();
|
|
834
|
+
const fonts = new Set();
|
|
835
|
+
|
|
836
|
+
const htmlFiles = fs.readdirSync(htmlDir).filter(f => f.endsWith('.html'));
|
|
837
|
+
|
|
838
|
+
for (const file of htmlFiles) {
|
|
839
|
+
const content = fs.readFileSync(path.join(htmlDir, file), 'utf-8');
|
|
840
|
+
|
|
841
|
+
// Extract colors
|
|
842
|
+
const colorMatches = content.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
|
|
843
|
+
colorMatches.forEach(color => {
|
|
844
|
+
const normalized = color.toLowerCase();
|
|
845
|
+
colors.set(normalized, (colors.get(normalized) || 0) + 1);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Extract RGB colors
|
|
849
|
+
const rgbMatches = content.match(/rgb\([^)]+\)/g) || [];
|
|
850
|
+
rgbMatches.forEach(rgb => {
|
|
851
|
+
colors.set(rgb, (colors.get(rgb) || 0) + 1);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Extract fonts
|
|
855
|
+
const fontMatches = content.match(/font-family:\s*([^;}"]+)/g) || [];
|
|
856
|
+
fontMatches.forEach(font => {
|
|
857
|
+
fonts.add(font.replace('font-family:', '').trim());
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Sort colors by frequency
|
|
862
|
+
const sortedColors = [...colors.entries()]
|
|
863
|
+
.sort((a, b) => b[1] - a[1])
|
|
864
|
+
.slice(0, 50);
|
|
865
|
+
|
|
866
|
+
// Categorize colors
|
|
867
|
+
const designTokens = {
|
|
868
|
+
extractedAt: new Date().toISOString(),
|
|
869
|
+
totalColorsFound: colors.size,
|
|
870
|
+
colors: this.categorizeColors(sortedColors),
|
|
871
|
+
fonts: {
|
|
872
|
+
families: [...fonts],
|
|
873
|
+
primary: [...fonts][0] || 'system-ui'
|
|
874
|
+
},
|
|
875
|
+
rawColors: sortedColors
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
fs.writeFileSync(
|
|
879
|
+
path.join(outputDir, 'design-tokens.json'),
|
|
880
|
+
JSON.stringify(designTokens, null, 2)
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
this.log(`Extracted ${colors.size} colors, ${fonts.size} fonts`, 'success');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
categorizeColors(sortedColors) {
|
|
887
|
+
const colors = {
|
|
888
|
+
primary: null,
|
|
889
|
+
secondary: null,
|
|
890
|
+
background: {},
|
|
891
|
+
text: {},
|
|
892
|
+
border: {},
|
|
893
|
+
status: {}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
for (const [color, count] of sortedColors) {
|
|
897
|
+
const hex = color.startsWith('#') ? color : this.rgbToHex(color);
|
|
898
|
+
const rgb = this.hexToRgb(hex);
|
|
899
|
+
if (!rgb) continue;
|
|
900
|
+
|
|
901
|
+
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
|
902
|
+
|
|
903
|
+
// Categorize based on color properties
|
|
904
|
+
if (rgb.b > rgb.r && rgb.b > rgb.g && !colors.primary) {
|
|
905
|
+
colors.primary = hex;
|
|
906
|
+
} else if (luminance > 0.9) {
|
|
907
|
+
if (!colors.background.white) colors.background.white = hex;
|
|
908
|
+
} else if (luminance > 0.8) {
|
|
909
|
+
if (!colors.background.light) colors.background.light = hex;
|
|
910
|
+
} else if (luminance < 0.2) {
|
|
911
|
+
if (!colors.text.primary) colors.text.primary = hex;
|
|
912
|
+
} else if (luminance < 0.5) {
|
|
913
|
+
if (!colors.text.secondary) colors.text.secondary = hex;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Status colors
|
|
917
|
+
if (rgb.r > 200 && rgb.g < 100 && rgb.b < 100) {
|
|
918
|
+
if (!colors.status.error) colors.status.error = hex;
|
|
919
|
+
}
|
|
920
|
+
if (rgb.g > 150 && rgb.r < 100 && rgb.b < 100) {
|
|
921
|
+
if (!colors.status.success) colors.status.success = hex;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return colors;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
hexToRgb(hex) {
|
|
929
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
930
|
+
return result ? {
|
|
931
|
+
r: parseInt(result[1], 16),
|
|
932
|
+
g: parseInt(result[2], 16),
|
|
933
|
+
b: parseInt(result[3], 16)
|
|
934
|
+
} : null;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
rgbToHex(rgb) {
|
|
938
|
+
const match = rgb.match(/\d+/g);
|
|
939
|
+
if (!match || match.length < 3) return null;
|
|
940
|
+
return '#' + match.slice(0, 3).map(x => {
|
|
941
|
+
const hex = parseInt(x).toString(16);
|
|
942
|
+
return hex.length === 1 ? '0' + hex : hex;
|
|
943
|
+
}).join('');
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async validate() {
|
|
947
|
+
this.log('Validating capture...', 'info');
|
|
948
|
+
|
|
949
|
+
const validation = this.config.validation || {};
|
|
950
|
+
const results = {
|
|
951
|
+
passed: true,
|
|
952
|
+
checks: []
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
// Check minimum pages
|
|
956
|
+
const minPages = validation.minPages || 5;
|
|
957
|
+
const pageCheck = {
|
|
958
|
+
name: 'Minimum pages captured',
|
|
959
|
+
expected: minPages,
|
|
960
|
+
actual: this.stats.pagesCaptured,
|
|
961
|
+
passed: this.stats.pagesCaptured >= minPages
|
|
962
|
+
};
|
|
963
|
+
results.checks.push(pageCheck);
|
|
964
|
+
if (!pageCheck.passed) results.passed = false;
|
|
965
|
+
|
|
966
|
+
// Check for detail pages
|
|
967
|
+
if (validation.requireDetailPages !== false) {
|
|
968
|
+
const listPages = [...this.capturedPages.keys()].filter(url =>
|
|
969
|
+
url.includes('list') || url.includes('accounts') || url.includes('contacts')
|
|
970
|
+
);
|
|
971
|
+
const detailPages = [...this.capturedPages.keys()].filter(url =>
|
|
972
|
+
url.includes('detail') || url.includes('-id') || url.match(/\/\d+\//)
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
const detailCheck = {
|
|
976
|
+
name: 'Detail pages captured',
|
|
977
|
+
expected: `${listPages.length} list pages should have detail pages`,
|
|
978
|
+
actual: `${detailPages.length} detail pages found`,
|
|
979
|
+
passed: listPages.length === 0 || detailPages.length > 0
|
|
980
|
+
};
|
|
981
|
+
results.checks.push(detailCheck);
|
|
982
|
+
if (!detailCheck.passed) {
|
|
983
|
+
results.passed = false;
|
|
984
|
+
this.warnings.push('List pages found but no detail pages captured');
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Check design tokens
|
|
989
|
+
const outputDir = this.config.output?.directory || './references';
|
|
990
|
+
const tokensPath = path.join(outputDir, 'design-tokens.json');
|
|
991
|
+
if (fs.existsSync(tokensPath)) {
|
|
992
|
+
const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
|
|
993
|
+
const minColors = validation.minColors || 10;
|
|
994
|
+
const colorCheck = {
|
|
995
|
+
name: 'Design tokens extracted',
|
|
996
|
+
expected: `At least ${minColors} colors`,
|
|
997
|
+
actual: `${tokens.totalColorsFound} colors`,
|
|
998
|
+
passed: tokens.totalColorsFound >= minColors
|
|
999
|
+
};
|
|
1000
|
+
results.checks.push(colorCheck);
|
|
1001
|
+
if (!colorCheck.passed) results.passed = false;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Check for errors
|
|
1005
|
+
const errorCheck = {
|
|
1006
|
+
name: 'No critical errors',
|
|
1007
|
+
expected: '0 errors',
|
|
1008
|
+
actual: `${this.errors.length} errors`,
|
|
1009
|
+
passed: this.errors.length === 0
|
|
1010
|
+
};
|
|
1011
|
+
results.checks.push(errorCheck);
|
|
1012
|
+
if (!errorCheck.passed) results.passed = false;
|
|
1013
|
+
|
|
1014
|
+
// Log results
|
|
1015
|
+
results.checks.forEach(check => {
|
|
1016
|
+
const icon = check.passed ? '✓' : '✗';
|
|
1017
|
+
this.log(`${icon} ${check.name}: ${check.actual}`, check.passed ? 'success' : 'error');
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
return results;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async generateManifest() {
|
|
1024
|
+
this.log('Generating manifest...', 'info');
|
|
1025
|
+
|
|
1026
|
+
const outputDir = this.config.output?.directory || './references';
|
|
1027
|
+
const manifest = {
|
|
1028
|
+
platform: {
|
|
1029
|
+
name: this.config.platform.name,
|
|
1030
|
+
baseUrl: this.config.platform.baseUrl,
|
|
1031
|
+
capturedAt: new Date().toISOString()
|
|
1032
|
+
},
|
|
1033
|
+
pages: [...this.capturedPages.entries()].map(([url, data]) => ({
|
|
1034
|
+
name: data.name,
|
|
1035
|
+
url: data.url,
|
|
1036
|
+
screenshot: data.captures[0]?.screenshot,
|
|
1037
|
+
html: data.html,
|
|
1038
|
+
description: this.generateDescription(data),
|
|
1039
|
+
captures: data.captures,
|
|
1040
|
+
tabs: data.tabs,
|
|
1041
|
+
interactions: data.interactions
|
|
1042
|
+
})),
|
|
1043
|
+
stats: this.stats,
|
|
1044
|
+
designTokens: 'design-tokens.json'
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
fs.writeFileSync(
|
|
1048
|
+
path.join(outputDir, 'manifest.json'),
|
|
1049
|
+
JSON.stringify(manifest, null, 2)
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
this.log('Manifest generated', 'success');
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
generateDescription(pageData) {
|
|
1056
|
+
const name = pageData.name;
|
|
1057
|
+
if (name.includes('detail')) return `${name.replace('-detail', '')} detail page`;
|
|
1058
|
+
if (name.includes('list')) return `${name.replace('-list', '')} list page`;
|
|
1059
|
+
if (name.includes('settings')) return 'Application settings';
|
|
1060
|
+
return `${name} page`;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
generateReport(validationResult) {
|
|
1064
|
+
console.log('\n' + '='.repeat(60));
|
|
1065
|
+
console.log('CAPTURE REPORT');
|
|
1066
|
+
console.log('='.repeat(60));
|
|
1067
|
+
|
|
1068
|
+
console.log('\nStatistics:');
|
|
1069
|
+
console.log(` Pages discovered: ${this.stats.pagesDiscovered}`);
|
|
1070
|
+
console.log(` Pages captured: ${this.stats.pagesCaptured}`);
|
|
1071
|
+
console.log(` Screenshots taken: ${this.stats.screenshotsTaken}`);
|
|
1072
|
+
console.log(` HTML files: ${this.stats.htmlCaptured}`);
|
|
1073
|
+
console.log(` Interactions: ${this.stats.interactionsPerformed}`);
|
|
1074
|
+
console.log(` Errors: ${this.stats.errorsEncountered}`);
|
|
1075
|
+
|
|
1076
|
+
if (this.warnings.length > 0) {
|
|
1077
|
+
console.log('\nWarnings:');
|
|
1078
|
+
this.warnings.forEach(w => console.log(` ⚠ ${w}`));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (this.errors.length > 0) {
|
|
1082
|
+
console.log('\nErrors:');
|
|
1083
|
+
this.errors.forEach(e => console.log(` ✗ ${e.url || e.phase}: ${e.error}`));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
console.log('\nValidation:');
|
|
1087
|
+
console.log(` Status: ${validationResult.passed ? 'PASSED ✓' : 'FAILED ✗'}`);
|
|
1088
|
+
|
|
1089
|
+
console.log('\n' + '='.repeat(60));
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// CLI Interface
|
|
1094
|
+
function parseArgs() {
|
|
1095
|
+
const args = process.argv.slice(2);
|
|
1096
|
+
const config = {
|
|
1097
|
+
platform: {},
|
|
1098
|
+
auth: { type: 'form', credentials: {} },
|
|
1099
|
+
capture: {},
|
|
1100
|
+
output: {},
|
|
1101
|
+
validation: {}
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
for (let i = 0; i < args.length; i++) {
|
|
1105
|
+
switch (args[i]) {
|
|
1106
|
+
case '--config':
|
|
1107
|
+
const configFile = args[++i];
|
|
1108
|
+
const fileConfig = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
1109
|
+
return fileConfig;
|
|
1110
|
+
case '--url':
|
|
1111
|
+
config.platform.baseUrl = args[++i];
|
|
1112
|
+
config.platform.name = new URL(config.platform.baseUrl).hostname;
|
|
1113
|
+
break;
|
|
1114
|
+
case '--email':
|
|
1115
|
+
config.auth.credentials.email = args[++i];
|
|
1116
|
+
break;
|
|
1117
|
+
case '--password':
|
|
1118
|
+
config.auth.credentials.password = args[++i];
|
|
1119
|
+
break;
|
|
1120
|
+
case '--output':
|
|
1121
|
+
config.output.directory = args[++i];
|
|
1122
|
+
break;
|
|
1123
|
+
case '--help':
|
|
1124
|
+
console.log(`
|
|
1125
|
+
Platform Capture Engine
|
|
1126
|
+
|
|
1127
|
+
Usage:
|
|
1128
|
+
node capture-engine.js --config ./config.json
|
|
1129
|
+
node capture-engine.js --url https://app.example.com --email user@test.com --password secret
|
|
1130
|
+
|
|
1131
|
+
Options:
|
|
1132
|
+
--config Path to JSON configuration file
|
|
1133
|
+
--url Platform base URL
|
|
1134
|
+
--email Login email
|
|
1135
|
+
--password Login password
|
|
1136
|
+
--output Output directory (default: ./references)
|
|
1137
|
+
--help Show this help
|
|
1138
|
+
`);
|
|
1139
|
+
process.exit(0);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return config;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Main
|
|
1147
|
+
const config = parseArgs();
|
|
1148
|
+
const engine = new CaptureEngine(config);
|
|
1149
|
+
engine.run().then(result => {
|
|
1150
|
+
process.exit(result.success ? 0 : 1);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
module.exports = { CaptureEngine };
|