@shadowcoderr/context-graph 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +448 -88
- package/dist/analyzers/a11y-extractor.d.ts +19 -5
- package/dist/analyzers/a11y-extractor.d.ts.map +1 -1
- package/dist/analyzers/a11y-extractor.js +274 -104
- package/dist/analyzers/a11y-extractor.js.map +1 -1
- package/dist/analyzers/network-logger.d.ts +20 -2
- package/dist/analyzers/network-logger.d.ts.map +1 -1
- package/dist/analyzers/network-logger.js +122 -42
- package/dist/analyzers/network-logger.js.map +1 -1
- package/dist/analyzers/network-patterns.d.ts +73 -0
- package/dist/analyzers/network-patterns.d.ts.map +1 -0
- package/dist/analyzers/network-patterns.js +316 -0
- package/dist/analyzers/network-patterns.js.map +1 -0
- package/dist/analyzers/screenshot-capturer.d.ts +73 -0
- package/dist/analyzers/screenshot-capturer.d.ts.map +1 -0
- package/dist/analyzers/screenshot-capturer.js +190 -0
- package/dist/analyzers/screenshot-capturer.js.map +1 -0
- package/dist/cli/index.js +15 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/core/capture-engine.d.ts +30 -25
- package/dist/core/capture-engine.d.ts.map +1 -1
- package/dist/core/capture-engine.js +290 -276
- package/dist/core/capture-engine.js.map +1 -1
- package/dist/exporters/ai-context-bundler.d.ts +88 -0
- package/dist/exporters/ai-context-bundler.d.ts.map +1 -0
- package/dist/exporters/ai-context-bundler.js +380 -0
- package/dist/exporters/ai-context-bundler.js.map +1 -0
- package/dist/security/redactor.d.ts +16 -0
- package/dist/security/redactor.d.ts.map +1 -1
- package/dist/security/redactor.js +127 -57
- package/dist/security/redactor.js.map +1 -1
- package/dist/storage/engine.d.ts +24 -21
- package/dist/storage/engine.d.ts.map +1 -1
- package/dist/storage/engine.js +208 -175
- package/dist/storage/engine.js.map +1 -1
- package/package.json +3 -3
|
@@ -40,6 +40,7 @@ const dom_analyzer_1 = require("../analyzers/dom-analyzer");
|
|
|
40
40
|
const a11y_extractor_1 = require("../analyzers/a11y-extractor");
|
|
41
41
|
const locator_generator_1 = require("../analyzers/locator-generator");
|
|
42
42
|
const network_logger_1 = require("../analyzers/network-logger");
|
|
43
|
+
const screenshot_capturer_1 = require("../analyzers/screenshot-capturer");
|
|
43
44
|
const redactor_1 = require("../security/redactor");
|
|
44
45
|
const validator_1 = require("../security/validator");
|
|
45
46
|
const logger_1 = require("../utils/logger");
|
|
@@ -48,6 +49,7 @@ class CaptureEngine {
|
|
|
48
49
|
a11yExtractor;
|
|
49
50
|
locatorGenerator;
|
|
50
51
|
networkLogger;
|
|
52
|
+
screenshotCapturer;
|
|
51
53
|
redactor;
|
|
52
54
|
validator;
|
|
53
55
|
constructor(config) {
|
|
@@ -55,33 +57,49 @@ class CaptureEngine {
|
|
|
55
57
|
this.a11yExtractor = new a11y_extractor_1.AccessibilityExtractor();
|
|
56
58
|
this.locatorGenerator = new locator_generator_1.LocatorGenerator();
|
|
57
59
|
this.redactor = new redactor_1.SecurityRedactor(config.security.customPatterns);
|
|
58
|
-
this.networkLogger = new network_logger_1.NetworkLogger(this.redactor
|
|
60
|
+
this.networkLogger = new network_logger_1.NetworkLogger(this.redactor, {
|
|
61
|
+
captureHeaders: config.capture.network.captureHeaders,
|
|
62
|
+
captureBody: config.capture.network.captureBody,
|
|
63
|
+
});
|
|
64
|
+
this.screenshotCapturer = new screenshot_capturer_1.ScreenshotCapturer({
|
|
65
|
+
fullPage: config.capture.screenshots.fullPage,
|
|
66
|
+
elementTargeting: config.capture.screenshots.elementTargeting,
|
|
67
|
+
});
|
|
59
68
|
this.validator = new validator_1.DataValidator();
|
|
60
69
|
}
|
|
61
70
|
async attachNetworkListeners(page) {
|
|
62
71
|
await this.networkLogger.attachListeners(page);
|
|
63
72
|
}
|
|
64
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Capture a full snapshot of the current page state.
|
|
75
|
+
*
|
|
76
|
+
* @param page — Playwright Page instance
|
|
77
|
+
* @param config — Active configuration
|
|
78
|
+
* @param consoleMessages — Console messages buffered by BrowserAdapter
|
|
79
|
+
* @param pageDir — If provided, full-page screenshots are captured here.
|
|
80
|
+
* The caller is responsible for ensuring the directory
|
|
81
|
+
* exists before passing this parameter.
|
|
82
|
+
*/
|
|
83
|
+
async capturePageSnapshot(page, config, consoleMessages = [], pageDir) {
|
|
65
84
|
logger_1.logger.info('Starting page capture');
|
|
66
85
|
const url = page.url();
|
|
67
86
|
const timestamp = new Date();
|
|
68
87
|
const domain = new URL(url).hostname;
|
|
69
|
-
|
|
70
|
-
// Add timeout to prevent hanging - increased for complex pages
|
|
71
|
-
const captureTimeout = 45000; // 45 seconds max per capture
|
|
88
|
+
const CAPTURE_TIMEOUT = 45_000;
|
|
72
89
|
const captureWithTimeout = async (promise, name) => {
|
|
73
90
|
try {
|
|
74
91
|
return await Promise.race([
|
|
75
92
|
promise,
|
|
76
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`${name}
|
|
93
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} capture timed out`)), CAPTURE_TIMEOUT)),
|
|
77
94
|
]);
|
|
78
95
|
}
|
|
79
96
|
catch (error) {
|
|
80
|
-
logger_1.logger.warn(`${name} capture failed
|
|
97
|
+
logger_1.logger.warn(`${name} capture failed: ${error.message}`);
|
|
81
98
|
return null;
|
|
82
99
|
}
|
|
83
100
|
};
|
|
84
|
-
|
|
101
|
+
// ── Primary captures (run in parallel) ─────────────────────────────────────
|
|
102
|
+
const [domResult, a11yTree, locators, performanceMetrics] = await Promise.all([
|
|
85
103
|
captureWithTimeout(this.domAnalyzer.analyze(page), 'DOM'),
|
|
86
104
|
config.capture.accessibility.enabled
|
|
87
105
|
? captureWithTimeout(this.a11yExtractor.extract(page, config.capture.accessibility.includeHidden), 'A11y')
|
|
@@ -90,149 +108,121 @@ class CaptureEngine {
|
|
|
90
108
|
captureWithTimeout(this.domAnalyzer.getPerformanceMetrics(page), 'Performance'),
|
|
91
109
|
]);
|
|
92
110
|
const frameContents = domResult?.frames || [];
|
|
93
|
-
const frames = await this.getFrameHierarchy(page, frameContents);
|
|
111
|
+
const frames = await captureWithTimeout(this.getFrameHierarchy(page, frameContents), 'Frames');
|
|
94
112
|
const networkEvents = this.networkLogger.getEvents();
|
|
95
|
-
//
|
|
113
|
+
// ── Secondary captures ─────────────────────────────────────────────────────
|
|
96
114
|
const [pageState, networkSummary, enhancedTiming] = await Promise.all([
|
|
97
115
|
captureWithTimeout(this.capturePageState(page), 'PageState'),
|
|
98
116
|
captureWithTimeout(this.captureNetworkSummary(networkEvents), 'NetworkSummary'),
|
|
99
117
|
captureWithTimeout(this.captureEnhancedTiming(page), 'EnhancedTiming'),
|
|
100
118
|
]);
|
|
101
119
|
const metadata = {
|
|
102
|
-
captureId: `${timestamp
|
|
120
|
+
captureId: `${timestamp
|
|
121
|
+
.toISOString()
|
|
122
|
+
.replace(/[-:]/g, '')
|
|
123
|
+
.replace('T', '_')
|
|
124
|
+
.split('.')[0]}_${domain.replace(/\./g, '_')}`,
|
|
103
125
|
timestamp: timestamp.toISOString(),
|
|
104
126
|
mode: 'browser',
|
|
105
127
|
url,
|
|
106
128
|
domain,
|
|
107
129
|
title: await page.title().catch(() => ''),
|
|
108
|
-
viewport: {
|
|
109
|
-
...config.browser.viewport,
|
|
110
|
-
deviceScaleFactor: 1,
|
|
111
|
-
},
|
|
130
|
+
viewport: { ...config.browser.viewport, deviceScaleFactor: 1 },
|
|
112
131
|
timing: enhancedTiming || {
|
|
113
132
|
navigationStart: Date.now() - 1000,
|
|
114
133
|
domContentLoaded: Date.now() - 500,
|
|
115
134
|
loadComplete: Date.now(),
|
|
116
135
|
networkIdle: Date.now(),
|
|
117
136
|
},
|
|
118
|
-
performance: performanceMetrics || {
|
|
137
|
+
performance: performanceMetrics || {
|
|
138
|
+
domNodes: 0, scripts: 0, stylesheets: 0, images: 0, totalRequests: 0,
|
|
139
|
+
},
|
|
119
140
|
userAgent: await page.evaluate(() => navigator.userAgent).catch(() => ''),
|
|
120
|
-
cookies: '[REDACTED]',
|
|
141
|
+
cookies: '[REDACTED]',
|
|
121
142
|
pageName: this.generatePageName(url),
|
|
122
143
|
pageState: pageState || undefined,
|
|
123
144
|
networkSummary: networkSummary || undefined,
|
|
124
|
-
contentHash: '',
|
|
145
|
+
contentHash: '',
|
|
125
146
|
};
|
|
147
|
+
const enhancedLocators = await captureWithTimeout(this.enhanceLocatorsData(page, locators || { elements: [] }), 'EnhancedLocators');
|
|
126
148
|
const snapshot = {
|
|
127
149
|
metadata,
|
|
128
150
|
domSnapshot: domResult?.html || '<html></html>',
|
|
129
151
|
a11yTree: a11yTree || { role: 'unknown', name: '', children: [] },
|
|
130
|
-
locators:
|
|
131
|
-
frames: frames || { url
|
|
152
|
+
locators: enhancedLocators || { elements: [] },
|
|
153
|
+
frames: frames || { url, name: '', children: [] },
|
|
132
154
|
networkEvents,
|
|
133
155
|
consoleMessages,
|
|
134
|
-
screenshotPaths: [],
|
|
156
|
+
screenshotPaths: [],
|
|
135
157
|
};
|
|
136
|
-
//
|
|
158
|
+
// ── Content hash (structural fingerprint) ───────────────────────────────────
|
|
137
159
|
snapshot.metadata.contentHash = this.computeContentHash(snapshot);
|
|
138
|
-
//
|
|
160
|
+
// ── Screenshots (only if caller provided a page directory) ─────────────────
|
|
161
|
+
if (config.capture.screenshots.enabled && pageDir) {
|
|
162
|
+
try {
|
|
163
|
+
const elementTargets = config.capture.screenshots.elementTargeting
|
|
164
|
+
? screenshot_capturer_1.ScreenshotCapturer.buildElementTargets(snapshot.locators.elements || [])
|
|
165
|
+
: [];
|
|
166
|
+
const screenshotResult = await captureWithTimeout(this.screenshotCapturer.capturePageScreenshots(page, pageDir, snapshot.metadata.captureId, elementTargets), 'Screenshots');
|
|
167
|
+
if (screenshotResult?.fullPagePath) {
|
|
168
|
+
snapshot.screenshotPaths = [screenshotResult.fullPagePath];
|
|
169
|
+
if (screenshotResult.elementPaths.length > 0) {
|
|
170
|
+
snapshot.screenshotPaths.push(...screenshotResult.elementPaths.map(e => e.path));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
logger_1.logger.warn(`Screenshot capture failed: ${error.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ── Validation ─────────────────────────────────────────────────────────────
|
|
139
179
|
const validation = this.validator.validatePageSnapshot(snapshot);
|
|
140
|
-
if (!validation.valid)
|
|
180
|
+
if (!validation.valid)
|
|
141
181
|
logger_1.logger.warn(`Validation errors: ${validation.errors.join(', ')}`);
|
|
142
|
-
|
|
143
|
-
if (validation.warnings.length > 0) {
|
|
182
|
+
if (validation.warnings.length > 0)
|
|
144
183
|
logger_1.logger.warn(`Validation warnings: ${validation.warnings.join(', ')}`);
|
|
145
|
-
}
|
|
146
184
|
logger_1.logger.info('Page capture completed');
|
|
147
185
|
return snapshot;
|
|
148
186
|
}
|
|
149
|
-
|
|
150
|
-
|
|
187
|
+
// ── Frame hierarchy ─────────────────────────────────────────────────────────
|
|
188
|
+
async getFrameHierarchy(page, frameContents = []) {
|
|
151
189
|
const frames = page.frames();
|
|
152
190
|
const buildHierarchy = (frame) => {
|
|
153
|
-
const
|
|
154
|
-
const contentEntry = (frameContents || []).find((f) => f.url === frame.url() && f.name === frame.name());
|
|
191
|
+
const contentEntry = frameContents.find(f => f.url === frame.url() && f.name === frame.name());
|
|
155
192
|
return {
|
|
156
193
|
url: frame.url(),
|
|
157
194
|
name: frame.name(),
|
|
158
|
-
children,
|
|
159
|
-
content: contentEntry
|
|
195
|
+
children: frame.childFrames().map(buildHierarchy),
|
|
196
|
+
content: contentEntry?.content,
|
|
160
197
|
};
|
|
161
198
|
};
|
|
162
199
|
return buildHierarchy(frames[0]);
|
|
163
200
|
}
|
|
164
|
-
|
|
165
|
-
return this.redactor.getAuditLog();
|
|
166
|
-
}
|
|
167
|
-
generatePageName(url) {
|
|
168
|
-
try {
|
|
169
|
-
const u = new URL(url);
|
|
170
|
-
const pathname = u.pathname.replace(/\/+$/g, ''); // strip trailing slash
|
|
171
|
-
// Handle root/index page
|
|
172
|
-
if (!pathname || pathname === '/') {
|
|
173
|
-
// If there are query params, include them
|
|
174
|
-
const params = Array.from(u.searchParams.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
175
|
-
if (params.length > 0) {
|
|
176
|
-
if (params.length === 1 && params[0][0] === 'id') {
|
|
177
|
-
return `index-${this.sanitize(params[0][1])}`;
|
|
178
|
-
}
|
|
179
|
-
const paramStr = params.map(([k, v]) => `${this.sanitize(k)}-${this.sanitize(v)}`).join('-');
|
|
180
|
-
return `index-${paramStr}`;
|
|
181
|
-
}
|
|
182
|
-
return 'index';
|
|
183
|
-
}
|
|
184
|
-
const segments = pathname.split('/').filter(Boolean);
|
|
185
|
-
// Remove extension of last segment
|
|
186
|
-
const last = segments[segments.length - 1];
|
|
187
|
-
const baseLast = last.replace(/\.html?$/i, '');
|
|
188
|
-
segments[segments.length - 1] = baseLast;
|
|
189
|
-
let base = segments.join('-');
|
|
190
|
-
// Handle query parameters
|
|
191
|
-
const params = Array.from(u.searchParams.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
192
|
-
if (params.length === 1 && params[0][0] === 'id') {
|
|
193
|
-
// Special case: if only 'id' param, append just the value
|
|
194
|
-
base = `${base}-${this.sanitize(params[0][1])}`;
|
|
195
|
-
}
|
|
196
|
-
else if (params.length > 0) {
|
|
197
|
-
// Multiple params: include key-value pairs
|
|
198
|
-
for (const [k, v] of params) {
|
|
199
|
-
base = `${base}-${this.sanitize(k)}-${this.sanitize(v)}`;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
base = this.sanitize(base);
|
|
203
|
-
return base || 'page';
|
|
204
|
-
}
|
|
205
|
-
catch (error) {
|
|
206
|
-
logger_1.logger.warn(`Failed to generate page name from URL: ${url}, error: ${error.message}`);
|
|
207
|
-
return 'page';
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
sanitize(name) {
|
|
211
|
-
return name.replace(/[^a-z0-9\-]/gi, '-').replace(/-+/g, '-').replace(/(^-|-$)/g, '').toLowerCase();
|
|
212
|
-
}
|
|
201
|
+
// ── Content hash ────────────────────────────────────────────────────────────
|
|
213
202
|
/**
|
|
214
|
-
* Compute a
|
|
215
|
-
*
|
|
203
|
+
* Compute a structural fingerprint of the page for change detection.
|
|
204
|
+
*
|
|
205
|
+
* Instead of truncating the raw HTML at 5 000 chars (which causes SPAs with
|
|
206
|
+
* large identical `<head>` sections to hash identically across routes), we
|
|
207
|
+
* extract a token stream of `tagName[#id][@role]` from the DOM. This is
|
|
208
|
+
* order-sensitive, compact, and captures the semantic structure without
|
|
209
|
+
* being affected by text-content changes that don't alter layout.
|
|
216
210
|
*/
|
|
217
211
|
computeContentHash(snapshot) {
|
|
218
212
|
try {
|
|
219
|
-
// Create a hashable representation of the page content
|
|
220
213
|
const hashContent = {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
a11y: this.normalizeA11yForHash(snapshot.a11yTree),
|
|
225
|
-
// Locator signatures (element IDs and their best locators)
|
|
226
|
-
locators: snapshot.locators.elements.map(e => ({
|
|
214
|
+
domStructure: this.normalizeDomForHash(snapshot.domSnapshot),
|
|
215
|
+
a11yStructure: this.normalizeA11yForHash(snapshot.a11yTree),
|
|
216
|
+
locatorSignatures: (snapshot.locators.elements || []).map(e => ({
|
|
227
217
|
id: e.elementId,
|
|
228
218
|
tag: e.tagName,
|
|
229
219
|
role: e.attributes?.role,
|
|
230
220
|
testId: e.attributes?.['data-testid'],
|
|
231
|
-
text: e.text?.substring(0, 50),
|
|
232
|
-
|
|
221
|
+
text: e.text?.substring(0, 50),
|
|
222
|
+
uniqueStrategies: e.locators.filter(l => l.isUnique).map(l => l.strategy),
|
|
233
223
|
})),
|
|
234
224
|
};
|
|
235
|
-
const hashString = JSON.stringify(hashContent
|
|
225
|
+
const hashString = JSON.stringify(hashContent);
|
|
236
226
|
return crypto.createHash('sha256').update(hashString).digest('hex').substring(0, 16);
|
|
237
227
|
}
|
|
238
228
|
catch (error) {
|
|
@@ -241,85 +231,126 @@ class CaptureEngine {
|
|
|
241
231
|
}
|
|
242
232
|
}
|
|
243
233
|
/**
|
|
244
|
-
*
|
|
234
|
+
* Extract a structural token stream from HTML rather than truncating text.
|
|
235
|
+
* Produces strings like `div#app@main|button|input@textbox` — semantically
|
|
236
|
+
* rich but immune to text-content noise and `<head>` boilerplate.
|
|
237
|
+
*
|
|
238
|
+
* Up to 500 tokens are extracted to bound memory usage while covering
|
|
239
|
+
* enough of the page to reliably detect structural changes.
|
|
245
240
|
*/
|
|
246
241
|
normalizeDomForHash(dom) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
242
|
+
const tagPattern = /<([a-z][a-z0-9]*)[^>]*?(?:\sid="([^"]*)")?[^>]*?(?:\srole="([^"]*)")?[^>]*/gi;
|
|
243
|
+
const tokens = [];
|
|
244
|
+
let match;
|
|
245
|
+
let count = 0;
|
|
246
|
+
while ((match = tagPattern.exec(dom)) !== null && count < 500) {
|
|
247
|
+
const tag = match[1];
|
|
248
|
+
const id = match[2] ? `#${match[2]}` : '';
|
|
249
|
+
const role = match[3] ? `@${match[3]}` : '';
|
|
250
|
+
tokens.push(`${tag}${id}${role}`);
|
|
251
|
+
count++;
|
|
252
|
+
}
|
|
253
|
+
return tokens.join('|');
|
|
257
254
|
}
|
|
258
|
-
/**
|
|
259
|
-
* Normalize accessibility tree for hashing
|
|
260
|
-
*/
|
|
261
255
|
normalizeA11yForHash(a11y) {
|
|
262
256
|
if (!a11y)
|
|
263
257
|
return null;
|
|
264
258
|
return {
|
|
265
259
|
role: a11y.role,
|
|
266
260
|
name: a11y.name?.substring(0, 50),
|
|
267
|
-
children: a11y.children?.map((c) => this.normalizeA11yForHash(c)),
|
|
261
|
+
children: a11y.children?.slice(0, 20).map((c) => this.normalizeA11yForHash(c)),
|
|
268
262
|
};
|
|
269
263
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
// ── Page name ───────────────────────────────────────────────────────────────
|
|
265
|
+
generatePageName(url) {
|
|
266
|
+
try {
|
|
267
|
+
const u = new URL(url);
|
|
268
|
+
const pathname = u.pathname.replace(/\/+$/, '');
|
|
269
|
+
let pageName = 'index';
|
|
270
|
+
if (pathname && pathname !== '/') {
|
|
271
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
272
|
+
segments[segments.length - 1] = segments[segments.length - 1].replace(/\.html?$/i, '');
|
|
273
|
+
pageName = segments.join('-');
|
|
274
|
+
}
|
|
275
|
+
const params = Array.from(u.searchParams.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
276
|
+
if (params.length > 0) {
|
|
277
|
+
if (params.length === 1 && params[0][0] === 'id') {
|
|
278
|
+
pageName = `${pageName}-${this.sanitizeName(params[0][1])}`;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const paramStr = params
|
|
282
|
+
.map(([k, v]) => `${this.sanitizeName(k)}-${this.sanitizeName(v)}`)
|
|
283
|
+
.join('-');
|
|
284
|
+
pageName = `${pageName}-${paramStr}`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const sanitized = this.sanitizeName(pageName) || 'page';
|
|
288
|
+
// Limit length to avoid ENOENT on long URLs (Windows limit is 260, but let's be safe with folder names)
|
|
289
|
+
if (sanitized.length > 100) {
|
|
290
|
+
const hash = crypto.createHash('md5').update(sanitized).digest('hex').substring(0, 8);
|
|
291
|
+
return sanitized.substring(0, 90) + '-' + hash;
|
|
292
|
+
}
|
|
293
|
+
return sanitized;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
logger_1.logger.warn(`Failed to generate page name from URL: ${url}`);
|
|
297
|
+
return 'page-' + crypto.randomBytes(4).toString('hex');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
sanitizeName(name) {
|
|
301
|
+
return name
|
|
302
|
+
.replace(/[^a-z0-9\-]/gi, '-')
|
|
303
|
+
.replace(/-+/g, '-')
|
|
304
|
+
.replace(/(^-|-$)/g, '')
|
|
305
|
+
.toLowerCase();
|
|
306
|
+
}
|
|
307
|
+
// ── Page state ──────────────────────────────────────────────────────────────
|
|
273
308
|
async capturePageState(page) {
|
|
274
309
|
try {
|
|
275
310
|
return await page.evaluate(() => {
|
|
276
|
-
const
|
|
277
|
-
const focusedElement =
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
311
|
+
const activeEl = document.activeElement;
|
|
312
|
+
const focusedElement = activeEl
|
|
313
|
+
? activeEl.id
|
|
314
|
+
? `#${activeEl.id}`
|
|
315
|
+
: activeEl.className
|
|
316
|
+
? `.${activeEl.className.split(' ')[0]}`
|
|
317
|
+
: activeEl.tagName.toLowerCase()
|
|
318
|
+
: undefined;
|
|
281
319
|
const selectedText = window.getSelection()?.toString() || '';
|
|
282
|
-
// Capture form data (redacted)
|
|
283
320
|
const formData = {};
|
|
284
|
-
|
|
285
|
-
forms.forEach((form, idx) => {
|
|
321
|
+
document.querySelectorAll('form').forEach((form, idx) => {
|
|
286
322
|
const formId = form.id || `form_${idx}`;
|
|
287
323
|
const inputs = {};
|
|
288
|
-
form.querySelectorAll('input, select, textarea').forEach((
|
|
289
|
-
const name =
|
|
290
|
-
if (
|
|
291
|
-
inputs[name] =
|
|
292
|
-
|
|
293
|
-
else if (input.type === 'password') {
|
|
324
|
+
form.querySelectorAll('input, select, textarea').forEach((el) => {
|
|
325
|
+
const name = el.name || el.id || `input_${idx}`;
|
|
326
|
+
if (el.type === 'checkbox' || el.type === 'radio')
|
|
327
|
+
inputs[name] = el.checked;
|
|
328
|
+
else if (el.type === 'password')
|
|
294
329
|
inputs[name] = '[REDACTED]';
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
inputs[name] = input.value ? '[REDACTED]' : '';
|
|
298
|
-
}
|
|
330
|
+
else
|
|
331
|
+
inputs[name] = el.value ? '[REDACTED]' : '';
|
|
299
332
|
});
|
|
300
|
-
if (Object.keys(inputs).length > 0)
|
|
333
|
+
if (Object.keys(inputs).length > 0)
|
|
301
334
|
formData[formId] = inputs;
|
|
302
|
-
}
|
|
303
335
|
});
|
|
304
|
-
// Capture storage keys (not values)
|
|
305
336
|
const localStorageKeys = {};
|
|
306
337
|
try {
|
|
307
338
|
for (let i = 0; i < window.localStorage.length; i++) {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
localStorageKeys[
|
|
339
|
+
const k = window.localStorage.key(i);
|
|
340
|
+
if (k)
|
|
341
|
+
localStorageKeys[k] = '[REDACTED]';
|
|
311
342
|
}
|
|
312
343
|
}
|
|
313
|
-
catch { }
|
|
344
|
+
catch { /* private browsing / security */ }
|
|
314
345
|
const sessionStorageKeys = {};
|
|
315
346
|
try {
|
|
316
347
|
for (let i = 0; i < window.sessionStorage.length; i++) {
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
319
|
-
sessionStorageKeys[
|
|
348
|
+
const k = window.sessionStorage.key(i);
|
|
349
|
+
if (k)
|
|
350
|
+
sessionStorageKeys[k] = '[REDACTED]';
|
|
320
351
|
}
|
|
321
352
|
}
|
|
322
|
-
catch { }
|
|
353
|
+
catch { /* private browsing / security */ }
|
|
323
354
|
return {
|
|
324
355
|
scrollPosition: { x: window.scrollX, y: window.scrollY },
|
|
325
356
|
focusedElement,
|
|
@@ -335,42 +366,34 @@ class CaptureEngine {
|
|
|
335
366
|
return null;
|
|
336
367
|
}
|
|
337
368
|
}
|
|
338
|
-
|
|
339
|
-
* Capture network request summary
|
|
340
|
-
*/
|
|
369
|
+
// ── Network summary ─────────────────────────────────────────────────────────
|
|
341
370
|
async captureNetworkSummary(networkEvents) {
|
|
342
371
|
try {
|
|
343
372
|
const requestTypes = {};
|
|
344
373
|
const apiEndpoints = [];
|
|
345
374
|
let failedRequests = 0;
|
|
346
375
|
for (const event of networkEvents) {
|
|
347
|
-
// Count by resource type
|
|
348
376
|
const type = event.resourceType || 'other';
|
|
349
377
|
requestTypes[type] = (requestTypes[type] || 0) + 1;
|
|
350
|
-
|
|
351
|
-
|
|
378
|
+
if (event.type === 'response' &&
|
|
379
|
+
event.status &&
|
|
380
|
+
(event.status < 200 || event.status >= 400)) {
|
|
352
381
|
failedRequests++;
|
|
353
382
|
}
|
|
354
|
-
// Extract API endpoints (XHR/Fetch to same domain or API-like URLs)
|
|
355
383
|
if ((type === 'xhr' || type === 'fetch') && event.url) {
|
|
356
384
|
try {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
event.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.method)) {
|
|
361
|
-
if (!apiEndpoints.includes(path)) {
|
|
362
|
-
apiEndpoints.push(path);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
385
|
+
const p = new URL(event.url).pathname;
|
|
386
|
+
if (!apiEndpoints.includes(p))
|
|
387
|
+
apiEndpoints.push(p);
|
|
365
388
|
}
|
|
366
|
-
catch { }
|
|
389
|
+
catch { /* non-parseable URL */ }
|
|
367
390
|
}
|
|
368
391
|
}
|
|
369
392
|
return {
|
|
370
393
|
totalRequests: networkEvents.length,
|
|
371
394
|
failedRequests,
|
|
372
395
|
requestTypes,
|
|
373
|
-
apiEndpoints: apiEndpoints.slice(0, 20),
|
|
396
|
+
apiEndpoints: apiEndpoints.slice(0, 20),
|
|
374
397
|
};
|
|
375
398
|
}
|
|
376
399
|
catch (error) {
|
|
@@ -378,84 +401,79 @@ class CaptureEngine {
|
|
|
378
401
|
return null;
|
|
379
402
|
}
|
|
380
403
|
}
|
|
381
|
-
|
|
382
|
-
* Capture enhanced timing metrics
|
|
383
|
-
*/
|
|
404
|
+
// ── Enhanced timing ─────────────────────────────────────────────────────────
|
|
384
405
|
async captureEnhancedTiming(page) {
|
|
385
406
|
try {
|
|
386
|
-
|
|
407
|
+
return await page.evaluate(() => {
|
|
387
408
|
const perf = window.performance;
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
const fcp =
|
|
391
|
-
const
|
|
392
|
-
const
|
|
409
|
+
const nav = perf.getEntriesByType('navigation')[0];
|
|
410
|
+
const paint = perf.getEntriesByType('paint');
|
|
411
|
+
const fcp = paint.find((e) => e.name === 'first-contentful-paint');
|
|
412
|
+
const lcpEntries = perf.getEntriesByType('largest-contentful-paint');
|
|
413
|
+
const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : null;
|
|
393
414
|
return {
|
|
394
|
-
navigationStart:
|
|
395
|
-
domContentLoaded:
|
|
396
|
-
loadComplete:
|
|
415
|
+
navigationStart: nav ? nav.fetchStart : Date.now() - 1000,
|
|
416
|
+
domContentLoaded: nav ? nav.domContentLoadedEventEnd : Date.now() - 500,
|
|
417
|
+
loadComplete: nav ? nav.loadEventEnd : Date.now(),
|
|
397
418
|
networkIdle: Date.now(),
|
|
398
419
|
firstContentfulPaint: fcp ? fcp.startTime : undefined,
|
|
399
|
-
largestContentfulPaint:
|
|
400
|
-
|
|
420
|
+
largestContentfulPaint: lcp
|
|
421
|
+
? lcp.renderTime || lcp.loadTime
|
|
422
|
+
: undefined,
|
|
423
|
+
timeToInteractive: nav ? nav.domInteractive : undefined,
|
|
401
424
|
};
|
|
402
425
|
});
|
|
403
|
-
return timing;
|
|
404
426
|
}
|
|
405
427
|
catch (error) {
|
|
406
428
|
logger_1.logger.warn('Failed to capture enhanced timing: ' + error.message);
|
|
407
429
|
return null;
|
|
408
430
|
}
|
|
409
431
|
}
|
|
410
|
-
|
|
411
|
-
* Enhance locators data with CSS styles, viewport info, and form details
|
|
412
|
-
*/
|
|
432
|
+
// ── Locator enhancement ─────────────────────────────────────────────────────
|
|
413
433
|
async enhanceLocatorsData(page, locatorsData) {
|
|
414
434
|
try {
|
|
415
|
-
// Build element selector map for efficient lookup
|
|
416
435
|
const elementMap = new Map();
|
|
417
436
|
for (const element of locatorsData.elements) {
|
|
418
|
-
// Create selector from element attributes
|
|
419
437
|
let selector = '';
|
|
420
438
|
if (element.attributes?.id) {
|
|
421
439
|
selector = `#${element.attributes.id}`;
|
|
422
440
|
}
|
|
423
|
-
else if (element.attributes?.['data-test']) {
|
|
424
|
-
selector = `[data-test="${element.attributes['data-test']}"]`;
|
|
425
|
-
}
|
|
426
441
|
else if (element.attributes?.['data-testid']) {
|
|
427
442
|
selector = `[data-testid="${element.attributes['data-testid']}"]`;
|
|
428
443
|
}
|
|
429
|
-
else if (element.
|
|
430
|
-
|
|
444
|
+
else if (element.attributes?.['data-test']) {
|
|
445
|
+
selector = `[data-test="${element.attributes['data-test']}"]`;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
431
448
|
selector = `[data-cc-element-id="${element.elementId}"]`;
|
|
432
449
|
}
|
|
433
|
-
if (selector)
|
|
450
|
+
if (selector)
|
|
434
451
|
elementMap.set(element.elementId, selector);
|
|
435
|
-
}
|
|
436
452
|
}
|
|
437
|
-
// Enhance elements with styles and viewport info
|
|
438
453
|
const enhancedElements = await Promise.all(locatorsData.elements.map(async (element) => {
|
|
439
454
|
try {
|
|
440
455
|
const selector = elementMap.get(element.elementId);
|
|
441
456
|
if (!selector)
|
|
442
457
|
return element;
|
|
443
|
-
|
|
444
|
-
|
|
458
|
+
const enhancement = await page
|
|
459
|
+
.evaluate(({ sel, pos }) => {
|
|
445
460
|
let el = null;
|
|
446
|
-
// Try to find element by selector
|
|
447
461
|
try {
|
|
448
462
|
el = document.querySelector(sel);
|
|
449
463
|
}
|
|
450
|
-
catch { }
|
|
451
|
-
//
|
|
452
|
-
if (!el && pos
|
|
453
|
-
el = document.elementFromPoint(pos.x, pos.y);
|
|
464
|
+
catch { /* invalid selector */ }
|
|
465
|
+
// Positional fallback when selector fails (e.g. complex CSS)
|
|
466
|
+
if (!el && pos?.x >= 0 && pos?.y >= 0) {
|
|
467
|
+
el = document.elementFromPoint(pos.x + 1, pos.y + 1);
|
|
454
468
|
}
|
|
455
469
|
if (!el)
|
|
456
470
|
return null;
|
|
457
471
|
const computed = window.getComputedStyle(el);
|
|
458
472
|
const rect = el.getBoundingClientRect();
|
|
473
|
+
const inViewport = rect.top >= 0 &&
|
|
474
|
+
rect.left >= 0 &&
|
|
475
|
+
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
|
476
|
+
rect.right <= (window.innerWidth || document.documentElement.clientWidth);
|
|
459
477
|
return {
|
|
460
478
|
styles: {
|
|
461
479
|
display: computed.display,
|
|
@@ -463,46 +481,57 @@ class CaptureEngine {
|
|
|
463
481
|
opacity: computed.opacity,
|
|
464
482
|
zIndex: computed.zIndex,
|
|
465
483
|
position: computed.position,
|
|
466
|
-
backgroundColor: computed.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
|
|
484
|
+
backgroundColor: computed.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
|
|
485
|
+
computed.backgroundColor !== 'transparent'
|
|
486
|
+
? computed.backgroundColor
|
|
487
|
+
: undefined,
|
|
467
488
|
color: computed.color,
|
|
468
489
|
fontSize: computed.fontSize,
|
|
469
490
|
fontWeight: computed.fontWeight,
|
|
470
|
-
border: computed.border !== '0px none rgb(0, 0, 0)' ? computed.border : undefined,
|
|
471
|
-
borderRadius: computed.borderRadius !== '0px' ? computed.borderRadius : undefined,
|
|
472
491
|
},
|
|
473
492
|
viewportInfo: {
|
|
474
|
-
visible: rect.width > 0 && rect.height > 0,
|
|
475
|
-
inViewport
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
493
|
+
visible: rect.width > 0 && rect.height > 0 && computed.visibility !== 'hidden' && computed.display !== 'none',
|
|
494
|
+
inViewport,
|
|
495
|
+
viewportPosition: { x: Math.round(rect.left), y: Math.round(rect.top) },
|
|
496
|
+
},
|
|
497
|
+
// Update bounding box with fresh data
|
|
498
|
+
position: {
|
|
499
|
+
x: Math.round(rect.left),
|
|
500
|
+
y: Math.round(rect.top),
|
|
501
|
+
width: Math.round(rect.width),
|
|
502
|
+
height: Math.round(rect.height),
|
|
479
503
|
},
|
|
480
504
|
};
|
|
481
|
-
}, { sel: selector, pos: element.position })
|
|
505
|
+
}, { sel: selector, pos: element.position })
|
|
506
|
+
.catch(() => null);
|
|
482
507
|
if (enhancement) {
|
|
483
508
|
return {
|
|
484
509
|
...element,
|
|
485
510
|
styles: enhancement.styles,
|
|
486
511
|
viewportInfo: enhancement.viewportInfo,
|
|
512
|
+
// Overwrite stale position data with fresh bounding box
|
|
513
|
+
position: enhancement.position ?? element.position,
|
|
514
|
+
computedState: {
|
|
515
|
+
...element.computedState,
|
|
516
|
+
isVisible: enhancement.viewportInfo.visible,
|
|
517
|
+
},
|
|
487
518
|
};
|
|
488
519
|
}
|
|
489
520
|
return element;
|
|
490
521
|
}
|
|
491
|
-
catch
|
|
522
|
+
catch {
|
|
492
523
|
return element;
|
|
493
524
|
}
|
|
494
525
|
}));
|
|
495
|
-
// Extract form field details
|
|
496
|
-
const formFields = await this.captureFormFieldDetails(page, locatorsData.elements);
|
|
497
|
-
// Build viewport elements list
|
|
498
526
|
const viewportElements = enhancedElements
|
|
499
527
|
.filter((el) => el.viewportInfo?.inViewport)
|
|
500
528
|
.map((el) => ({
|
|
501
529
|
elementId: el.elementId,
|
|
502
|
-
visible: el.viewportInfo
|
|
503
|
-
inViewport: el.viewportInfo
|
|
504
|
-
viewportPosition: el.viewportInfo
|
|
530
|
+
visible: el.viewportInfo.visible,
|
|
531
|
+
inViewport: el.viewportInfo.inViewport,
|
|
532
|
+
viewportPosition: el.viewportInfo.viewportPosition,
|
|
505
533
|
}));
|
|
534
|
+
const formFields = await this.captureFormFieldDetails(page, locatorsData.elements);
|
|
506
535
|
return {
|
|
507
536
|
elements: enhancedElements,
|
|
508
537
|
viewportElements: viewportElements.length > 0 ? viewportElements : undefined,
|
|
@@ -514,79 +543,64 @@ class CaptureEngine {
|
|
|
514
543
|
return locatorsData;
|
|
515
544
|
}
|
|
516
545
|
}
|
|
517
|
-
/**
|
|
518
|
-
* Capture form field details
|
|
519
|
-
*/
|
|
520
546
|
async captureFormFieldDetails(page, elements) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const el = document.querySelector(sel);
|
|
545
|
-
if (!el)
|
|
546
|
-
return null;
|
|
547
|
-
// Get label
|
|
548
|
-
let label;
|
|
549
|
-
if (el.id) {
|
|
550
|
-
const labelEl = document.querySelector(`label[for="${el.id}"]`);
|
|
551
|
-
label = labelEl?.textContent?.trim();
|
|
552
|
-
}
|
|
553
|
-
if (!label && el.closest('label')) {
|
|
554
|
-
label = el.closest('label')?.textContent?.trim();
|
|
555
|
-
}
|
|
556
|
-
return {
|
|
557
|
-
fieldType: el.type || tag,
|
|
558
|
-
label,
|
|
559
|
-
placeholder: el.placeholder || undefined,
|
|
560
|
-
required: el.hasAttribute('required'),
|
|
561
|
-
pattern: el.pattern || undefined,
|
|
562
|
-
maxLength: el.maxLength > 0 ? el.maxLength : undefined,
|
|
563
|
-
minLength: el.minLength > 0 ? el.minLength : undefined,
|
|
564
|
-
validationRules: {
|
|
565
|
-
required: el.hasAttribute('required'),
|
|
566
|
-
pattern: el.pattern || undefined,
|
|
567
|
-
maxLength: el.maxLength > 0 ? el.maxLength : undefined,
|
|
568
|
-
minLength: el.minLength > 0 ? el.minLength : undefined,
|
|
569
|
-
},
|
|
570
|
-
};
|
|
571
|
-
}, { sel: selector, tag: tagName }).catch(() => null);
|
|
572
|
-
if (details) {
|
|
573
|
-
formFields.push({
|
|
574
|
-
elementId: element.elementId,
|
|
575
|
-
...details,
|
|
576
|
-
});
|
|
577
|
-
}
|
|
547
|
+
const formFields = [];
|
|
548
|
+
for (const element of elements) {
|
|
549
|
+
const tagName = element.tagName?.toLowerCase();
|
|
550
|
+
if (!['input', 'select', 'textarea'].includes(tagName))
|
|
551
|
+
continue;
|
|
552
|
+
try {
|
|
553
|
+
let selector = '';
|
|
554
|
+
if (element.attributes?.id)
|
|
555
|
+
selector = `#${element.attributes.id}`;
|
|
556
|
+
else if (element.attributes?.['data-testid'])
|
|
557
|
+
selector = `[data-testid="${element.attributes['data-testid']}"]`;
|
|
558
|
+
else if (element.attributes?.name)
|
|
559
|
+
selector = `${tagName}[name="${element.attributes.name}"]`;
|
|
560
|
+
else
|
|
561
|
+
continue;
|
|
562
|
+
const details = await page
|
|
563
|
+
.evaluate(({ sel, tag }) => {
|
|
564
|
+
const el = document.querySelector(sel);
|
|
565
|
+
if (!el)
|
|
566
|
+
return null;
|
|
567
|
+
let label;
|
|
568
|
+
if (el.id) {
|
|
569
|
+
label = document.querySelector(`label[for="${el.id}"]`)?.textContent?.trim();
|
|
578
570
|
}
|
|
579
|
-
|
|
580
|
-
|
|
571
|
+
if (!label) {
|
|
572
|
+
label = el.closest('label')?.textContent?.trim();
|
|
581
573
|
}
|
|
574
|
+
return {
|
|
575
|
+
fieldType: el.type || tag,
|
|
576
|
+
label,
|
|
577
|
+
placeholder: el.placeholder || undefined,
|
|
578
|
+
required: el.hasAttribute('required'),
|
|
579
|
+
pattern: el.pattern || undefined,
|
|
580
|
+
maxLength: el.maxLength > 0
|
|
581
|
+
? el.maxLength
|
|
582
|
+
: undefined,
|
|
583
|
+
minLength: el.minLength > 0
|
|
584
|
+
? el.minLength
|
|
585
|
+
: undefined,
|
|
586
|
+
};
|
|
587
|
+
}, { sel: selector, tag: tagName })
|
|
588
|
+
.catch(() => null);
|
|
589
|
+
if (details) {
|
|
590
|
+
formFields.push({ elementId: element.elementId, ...details });
|
|
582
591
|
}
|
|
583
592
|
}
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
catch (error) {
|
|
587
|
-
logger_1.logger.warn('Failed to capture form field details: ' + error.message);
|
|
588
|
-
return [];
|
|
593
|
+
catch { /* skip this element */ }
|
|
589
594
|
}
|
|
595
|
+
return formFields;
|
|
596
|
+
}
|
|
597
|
+
// ── Redaction audit ─────────────────────────────────────────────────────────
|
|
598
|
+
getRedactionAudit() {
|
|
599
|
+
return this.redactor.getAuditLog();
|
|
600
|
+
}
|
|
601
|
+
/** Network event count — passed to StorageEngine for statistics */
|
|
602
|
+
getNetworkEventCount() {
|
|
603
|
+
return this.networkLogger.getRequestCount();
|
|
590
604
|
}
|
|
591
605
|
}
|
|
592
606
|
exports.CaptureEngine = CaptureEngine;
|