@shadowcoderr/context-graph 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +439 -88
  2. package/dist/analyzers/a11y-extractor.d.ts +19 -5
  3. package/dist/analyzers/a11y-extractor.d.ts.map +1 -1
  4. package/dist/analyzers/a11y-extractor.js +274 -104
  5. package/dist/analyzers/a11y-extractor.js.map +1 -1
  6. package/dist/analyzers/network-logger.d.ts +20 -2
  7. package/dist/analyzers/network-logger.d.ts.map +1 -1
  8. package/dist/analyzers/network-logger.js +122 -42
  9. package/dist/analyzers/network-logger.js.map +1 -1
  10. package/dist/analyzers/network-patterns.d.ts +73 -0
  11. package/dist/analyzers/network-patterns.d.ts.map +1 -0
  12. package/dist/analyzers/network-patterns.js +316 -0
  13. package/dist/analyzers/network-patterns.js.map +1 -0
  14. package/dist/analyzers/page-notifier.d.ts +40 -0
  15. package/dist/analyzers/page-notifier.d.ts.map +1 -0
  16. package/dist/analyzers/page-notifier.js +198 -0
  17. package/dist/analyzers/page-notifier.js.map +1 -0
  18. package/dist/analyzers/screenshot-capturer.d.ts +73 -0
  19. package/dist/analyzers/screenshot-capturer.d.ts.map +1 -0
  20. package/dist/analyzers/screenshot-capturer.js +190 -0
  21. package/dist/analyzers/screenshot-capturer.js.map +1 -0
  22. package/dist/cli/index.js +15 -6
  23. package/dist/cli/index.js.map +1 -1
  24. package/dist/config/defaults.d.ts.map +1 -1
  25. package/dist/config/defaults.js +3 -1
  26. package/dist/config/defaults.js.map +1 -1
  27. package/dist/config/schema.d.ts +8 -3
  28. package/dist/config/schema.d.ts.map +1 -1
  29. package/dist/config/schema.js +7 -2
  30. package/dist/config/schema.js.map +1 -1
  31. package/dist/core/browser-adapter.d.ts.map +1 -1
  32. package/dist/core/browser-adapter.js +0 -2
  33. package/dist/core/browser-adapter.js.map +1 -1
  34. package/dist/core/capture-engine.d.ts +30 -25
  35. package/dist/core/capture-engine.d.ts.map +1 -1
  36. package/dist/core/capture-engine.js +290 -276
  37. package/dist/core/capture-engine.js.map +1 -1
  38. package/dist/core/runtime.d.ts +1 -0
  39. package/dist/core/runtime.d.ts.map +1 -1
  40. package/dist/core/runtime.js +21 -0
  41. package/dist/core/runtime.js.map +1 -1
  42. package/dist/exporters/ai-context-bundler.d.ts +88 -0
  43. package/dist/exporters/ai-context-bundler.d.ts.map +1 -0
  44. package/dist/exporters/ai-context-bundler.js +380 -0
  45. package/dist/exporters/ai-context-bundler.js.map +1 -0
  46. package/dist/security/redactor.d.ts +16 -0
  47. package/dist/security/redactor.d.ts.map +1 -1
  48. package/dist/security/redactor.js +127 -57
  49. package/dist/security/redactor.js.map +1 -1
  50. package/dist/storage/engine.d.ts +24 -21
  51. package/dist/storage/engine.d.ts.map +1 -1
  52. package/dist/storage/engine.js +208 -175
  53. package/dist/storage/engine.js.map +1 -1
  54. package/dist/types/config.d.ts +4 -1
  55. package/dist/types/config.d.ts.map +1 -1
  56. package/dist/types/notifications.d.ts +37 -0
  57. package/dist/types/notifications.d.ts.map +1 -0
  58. package/dist/types/notifications.js +4 -0
  59. package/dist/types/notifications.js.map +1 -0
  60. package/package.json +71 -70
@@ -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
- async capturePageSnapshot(page, config, consoleMessages = []) {
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
- // Capture all data in parallel where possible
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} timeout`)), captureTimeout))
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 or timed out: ${error.message}`);
97
+ logger_1.logger.warn(`${name} capture failed: ${error.message}`);
81
98
  return null;
82
99
  }
83
100
  };
84
- const [domResult, a11yTree, locators, performanceMetrics,] = await Promise.all([
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
- // Capture enhanced data
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.toISOString().replace(/[-:]/g, '').replace('T', '_').split('.')[0]}_${domain.replace(/\./g, '_')}`,
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 || { domNodes: 0, scripts: 0, stylesheets: 0, images: 0, totalRequests: 0 },
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]', // Always redact cookies
141
+ cookies: '[REDACTED]',
121
142
  pageName: this.generatePageName(url),
122
143
  pageState: pageState || undefined,
123
144
  networkSummary: networkSummary || undefined,
124
- contentHash: '', // Will be computed after snapshot is built
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: await this.enhanceLocatorsData(page, locators || { elements: [] }),
131
- frames: frames || { url: url, name: '', children: [] },
152
+ locators: enhancedLocators || { elements: [] },
153
+ frames: frames || { url, name: '', children: [] },
132
154
  networkEvents,
133
155
  consoleMessages,
134
- screenshotPaths: [], // Placeholder
156
+ screenshotPaths: [],
135
157
  };
136
- // Compute content hash for change detection
158
+ // ── Content hash (structural fingerprint) ───────────────────────────────────
137
159
  snapshot.metadata.contentHash = this.computeContentHash(snapshot);
138
- // Validate the snapshot
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
- async getFrameHierarchy(page, frameContents) {
150
- // Build hierarchy and attach serialized content if available
187
+ // ── Frame hierarchy ─────────────────────────────────────────────────────────
188
+ async getFrameHierarchy(page, frameContents = []) {
151
189
  const frames = page.frames();
152
190
  const buildHierarchy = (frame) => {
153
- const children = frame.childFrames().map(buildHierarchy);
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 ? contentEntry.content : undefined,
195
+ children: frame.childFrames().map(buildHierarchy),
196
+ content: contentEntry?.content,
160
197
  };
161
198
  };
162
199
  return buildHierarchy(frames[0]);
163
200
  }
164
- getRedactionAudit() {
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 content hash for change detection
215
- * Combines DOM, accessibility tree, and locators into a single hash
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
- // DOM structure (normalized)
222
- dom: this.normalizeDomForHash(snapshot.domSnapshot),
223
- // Accessibility tree structure
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), // Limit text length
232
- uniqueLocators: e.locators.filter(l => l.isUnique).map(l => l.strategy),
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, Object.keys(hashContent).sort());
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
- * Normalize DOM for hashing by removing dynamic content
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
- // Remove scripts, styles, and dynamic attributes
248
- let normalized = dom
249
- .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
250
- .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
251
- .replace(/\s+(class|id|style)="[^"]*"/g, '') // Remove class/id/style attributes
252
- .replace(/\s+data-cc-element-id="[^"]*"/g, '') // Remove our own tracking attribute
253
- .replace(/\s+/g, ' ') // Normalize whitespace
254
- .trim();
255
- // Limit length for performance
256
- return normalized.substring(0, 5000);
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
- * Capture page state information
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 activeElement = document.activeElement;
277
- const focusedElement = activeElement ?
278
- (activeElement.id ? `#${activeElement.id}` :
279
- activeElement.className ? `.${activeElement.className.split(' ')[0]}` :
280
- activeElement.tagName.toLowerCase()) : undefined;
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
- const forms = document.querySelectorAll('form');
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((input) => {
289
- const name = input.name || input.id || `input_${idx}`;
290
- if (input.type === 'checkbox' || input.type === 'radio') {
291
- inputs[name] = input.checked;
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
- else {
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 key = window.localStorage.key(i);
309
- if (key)
310
- localStorageKeys[key] = '[REDACTED]';
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 key = window.sessionStorage.key(i);
318
- if (key)
319
- sessionStorageKeys[key] = '[REDACTED]';
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
- // Track failed requests
351
- if (event.type === 'response' && event.status && (event.status < 200 || event.status >= 400)) {
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 url = new URL(event.url);
358
- const path = url.pathname;
359
- if (path.startsWith('/api/') || path.includes('/api/') ||
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), // Limit to 20 endpoints
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
- const timing = await page.evaluate(() => {
407
+ return await page.evaluate(() => {
387
408
  const perf = window.performance;
388
- const navTiming = perf.getEntriesByType('navigation')[0];
389
- const paintTiming = perf.getEntriesByType('paint');
390
- const fcp = paintTiming.find((entry) => entry.name === 'first-contentful-paint');
391
- const lcp = perf.getEntriesByType('largest-contentful-paint');
392
- const lcpEntry = lcp.length > 0 ? lcp[lcp.length - 1] : null;
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: navTiming ? navTiming.navigationStart || navTiming.fetchStart : Date.now() - 1000,
395
- domContentLoaded: navTiming ? navTiming.domContentLoadedEventEnd : Date.now() - 500,
396
- loadComplete: navTiming ? navTiming.loadEventEnd : Date.now(),
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: lcpEntry ? lcpEntry.renderTime || lcpEntry.loadTime : undefined,
400
- timeToInteractive: navTiming ? navTiming.domInteractive : undefined,
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.position && element.position.x >= 0 && element.position.y >= 0) {
430
- // Use position as fallback - find element at this position
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
- // Get CSS styles and viewport info in one call
444
- const enhancement = await page.evaluate(({ sel, pos }) => {
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
- // Fallback: find by position if selector fails
452
- if (!el && pos && pos.x >= 0 && pos.y >= 0) {
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)' && computed.backgroundColor !== 'transparent' ? computed.backgroundColor : undefined,
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: rect.top >= 0 && rect.left >= 0 &&
476
- rect.bottom <= window.innerHeight &&
477
- rect.right <= window.innerWidth,
478
- viewportPosition: { x: rect.left, y: rect.top },
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 }).catch(() => null);
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 (error) {
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?.visible || false,
503
- inViewport: el.viewportInfo?.inViewport || false,
504
- viewportPosition: el.viewportInfo?.viewportPosition || { x: 0, y: 0 },
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
- try {
522
- const formFields = [];
523
- for (const element of elements) {
524
- const tagName = element.tagName?.toLowerCase();
525
- if (tagName === 'input' || tagName === 'select' || tagName === 'textarea') {
526
- try {
527
- // Build selector from element attributes
528
- let selector = '';
529
- if (element.attributes?.id) {
530
- selector = `#${element.attributes.id}`;
531
- }
532
- else if (element.attributes?.['data-test']) {
533
- selector = `[data-test="${element.attributes['data-test']}"]`;
534
- }
535
- else if (element.attributes?.['data-testid']) {
536
- selector = `[data-testid="${element.attributes['data-testid']}"]`;
537
- }
538
- else if (element.attributes?.name) {
539
- selector = `${tagName}[name="${element.attributes.name}"]`;
540
- }
541
- if (!selector)
542
- continue;
543
- const details = await page.evaluate(({ sel, tag }) => {
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
- catch (error) {
580
- // Skip this element
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
- return formFields;
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;