chrometools-mcp 2.5.0 → 3.1.2
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/CHANGELOG.md +420 -0
- package/COMPONENT_MAPPING_SPEC.md +1217 -0
- package/README.md +406 -38
- package/bridge/bridge-client.js +472 -0
- package/bridge/bridge-service.js +399 -0
- package/bridge/install.js +241 -0
- package/browser/browser-manager.js +107 -2
- package/browser/page-manager.js +226 -69
- package/docs/CHROME_EXTENSION.md +219 -0
- package/docs/PAGE_OBJECT_MODEL_CONCEPT.md +1756 -0
- package/extension/background.js +643 -0
- package/extension/content.js +715 -0
- package/extension/icons/create-icons.js +164 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +58 -0
- package/extension/popup/popup.css +437 -0
- package/extension/popup/popup.html +102 -0
- package/extension/popup/popup.js +415 -0
- package/extension/recorder-overlay.css +93 -0
- package/index.js +3347 -2901
- package/models/BaseInputModel.js +93 -0
- package/models/CheckboxGroupModel.js +199 -0
- package/models/CheckboxModel.js +103 -0
- package/models/ColorInputModel.js +53 -0
- package/models/DateInputModel.js +67 -0
- package/models/RadioGroupModel.js +126 -0
- package/models/RangeInputModel.js +60 -0
- package/models/SelectModel.js +97 -0
- package/models/TextInputModel.js +34 -0
- package/models/TextareaModel.js +59 -0
- package/models/TimeInputModel.js +49 -0
- package/models/index.js +122 -0
- package/package.json +3 -2
- package/pom/apom-converter.js +267 -0
- package/pom/apom-tree-converter.js +515 -0
- package/pom/element-id-generator.js +175 -0
- package/recorder/page-object-generator.js +16 -0
- package/recorder/scenario-executor.js +80 -2
- package/server/tool-definitions.js +839 -713
- package/server/tool-groups.js +1 -1
- package/server/tool-schemas.js +367 -326
- package/server/websocket-bridge.js +447 -0
- package/utils/selector-resolver.js +186 -0
- package/utils/ui-framework-detector.js +392 -0
- package/RELEASE_NOTES_v2.5.0.md +0 -109
- package/npm_publish_output.txt +0 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChromeTools MCP Extension - Content Script
|
|
3
|
+
*
|
|
4
|
+
* Injected into every page to:
|
|
5
|
+
* - Record user actions (click, type, select, etc.)
|
|
6
|
+
* - Show recording overlay
|
|
7
|
+
* - Generate selectors for elements
|
|
8
|
+
* - Detect and mask secrets
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
(function() {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// Prevent multiple injections
|
|
15
|
+
if (window.__chrometoolsContentScript) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
window.__chrometoolsContentScript = true;
|
|
19
|
+
|
|
20
|
+
// ============================================
|
|
21
|
+
// State
|
|
22
|
+
// ============================================
|
|
23
|
+
|
|
24
|
+
let isRecording = false;
|
|
25
|
+
let isPaused = false;
|
|
26
|
+
let overlay = null;
|
|
27
|
+
let actionCount = 0;
|
|
28
|
+
|
|
29
|
+
// Debounce timers
|
|
30
|
+
const inputDebounceTimers = new Map();
|
|
31
|
+
const lastInputValues = new Map(); // Value when element was last recorded
|
|
32
|
+
const inputStartValues = new Map(); // Value when user started typing (before any input)
|
|
33
|
+
let scrollDebounceTimer = null;
|
|
34
|
+
let lastHoverTarget = null;
|
|
35
|
+
|
|
36
|
+
// ============================================
|
|
37
|
+
// Selector Generator
|
|
38
|
+
// ============================================
|
|
39
|
+
|
|
40
|
+
function generateSelector(element) {
|
|
41
|
+
if (!element || element === document.body || element === document.documentElement) {
|
|
42
|
+
return { primary: 'body', fallbacks: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const selectors = [];
|
|
46
|
+
|
|
47
|
+
// 1. ID selector (highest priority)
|
|
48
|
+
if (element.id && !isGeneratedId(element.id)) {
|
|
49
|
+
selectors.push(`#${CSS.escape(element.id)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. data-testid, data-cy, data-test attributes
|
|
53
|
+
const testAttrs = ['data-testid', 'data-cy', 'data-test', 'data-qa'];
|
|
54
|
+
for (const attr of testAttrs) {
|
|
55
|
+
const value = element.getAttribute(attr);
|
|
56
|
+
if (value) {
|
|
57
|
+
selectors.push(`[${attr}="${CSS.escape(value)}"]`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. name attribute (for form elements)
|
|
62
|
+
if (element.name) {
|
|
63
|
+
const tag = element.tagName.toLowerCase();
|
|
64
|
+
selectors.push(`${tag}[name="${CSS.escape(element.name)}"]`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 4. aria-label
|
|
68
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
69
|
+
if (ariaLabel) {
|
|
70
|
+
selectors.push(`[aria-label="${CSS.escape(ariaLabel)}"]`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 5. Type + placeholder for inputs
|
|
74
|
+
if (element.tagName === 'INPUT') {
|
|
75
|
+
const type = element.type || 'text';
|
|
76
|
+
const placeholder = element.placeholder;
|
|
77
|
+
if (placeholder) {
|
|
78
|
+
selectors.push(`input[type="${type}"][placeholder="${CSS.escape(placeholder)}"]`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 6. Button/link text
|
|
83
|
+
if (['BUTTON', 'A'].includes(element.tagName)) {
|
|
84
|
+
const text = element.textContent?.trim();
|
|
85
|
+
if (text && text.length < 50) {
|
|
86
|
+
// Use XPath for text matching (will be converted on server side)
|
|
87
|
+
selectors.push(`//${element.tagName.toLowerCase()}[contains(text(),"${text.substring(0, 30)}")]`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 7. CSS class combination (avoid dynamic classes)
|
|
92
|
+
const stableClasses = getStableClasses(element);
|
|
93
|
+
if (stableClasses.length > 0) {
|
|
94
|
+
const tag = element.tagName.toLowerCase();
|
|
95
|
+
selectors.push(`${tag}.${stableClasses.join('.')}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 8. Fallback: nth-child path
|
|
99
|
+
const nthPath = getNthChildPath(element);
|
|
100
|
+
if (nthPath) {
|
|
101
|
+
selectors.push(nthPath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate selectors
|
|
105
|
+
const validSelectors = selectors.filter(sel => {
|
|
106
|
+
if (sel.startsWith('//')) return true; // XPath
|
|
107
|
+
try {
|
|
108
|
+
const matches = document.querySelectorAll(sel);
|
|
109
|
+
return matches.length === 1 || (matches.length > 0 && matches[0] === element);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
primary: validSelectors[0] || nthPath || 'body',
|
|
117
|
+
fallbacks: validSelectors.slice(1, 4),
|
|
118
|
+
elementInfo: {
|
|
119
|
+
tag: element.tagName.toLowerCase(),
|
|
120
|
+
id: element.id || null,
|
|
121
|
+
classes: Array.from(element.classList),
|
|
122
|
+
text: element.textContent?.trim().substring(0, 50) || null
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isGeneratedId(id) {
|
|
128
|
+
// Common patterns for auto-generated IDs
|
|
129
|
+
const patterns = [
|
|
130
|
+
/^:r[0-9a-z]+:$/i, // React 18
|
|
131
|
+
/^[a-f0-9]{8,}$/i, // UUID-like
|
|
132
|
+
/^ember\d+$/i, // Ember
|
|
133
|
+
/^ng-\d+$/i, // Angular
|
|
134
|
+
/^uid-\d+$/i, // Generic
|
|
135
|
+
/^\d+$/, // Pure numbers
|
|
136
|
+
];
|
|
137
|
+
return patterns.some(p => p.test(id));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getStableClasses(element) {
|
|
141
|
+
const unstablePatterns = [
|
|
142
|
+
/^css-[a-z0-9]+$/i, // CSS modules
|
|
143
|
+
/^sc-[a-z]+$/i, // styled-components
|
|
144
|
+
/^_[a-z0-9]+_[a-z0-9]+$/i, // CSS modules variant
|
|
145
|
+
/^[a-z]+-[a-f0-9]{5,}$/i, // Hash-based
|
|
146
|
+
/^jsx-\d+$/i, // styled-jsx
|
|
147
|
+
/^emotion-\d+$/i, // Emotion
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
return Array.from(element.classList).filter(cls => {
|
|
151
|
+
if (cls.length < 2) return false;
|
|
152
|
+
return !unstablePatterns.some(p => p.test(cls));
|
|
153
|
+
}).slice(0, 3);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getNthChildPath(element, maxDepth = 5) {
|
|
157
|
+
const path = [];
|
|
158
|
+
let current = element;
|
|
159
|
+
let depth = 0;
|
|
160
|
+
|
|
161
|
+
while (current && current !== document.body && depth < maxDepth) {
|
|
162
|
+
const parent = current.parentElement;
|
|
163
|
+
if (!parent) break;
|
|
164
|
+
|
|
165
|
+
const siblings = Array.from(parent.children).filter(
|
|
166
|
+
el => el.tagName === current.tagName
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (siblings.length === 1) {
|
|
170
|
+
path.unshift(current.tagName.toLowerCase());
|
|
171
|
+
} else {
|
|
172
|
+
const index = siblings.indexOf(current) + 1;
|
|
173
|
+
path.unshift(`${current.tagName.toLowerCase()}:nth-of-type(${index})`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
current = parent;
|
|
177
|
+
depth++;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return path.length > 0 ? path.join(' > ') : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================
|
|
184
|
+
// Secret Detection
|
|
185
|
+
// ============================================
|
|
186
|
+
|
|
187
|
+
function isSecretField(element) {
|
|
188
|
+
const type = (element.type || '').toLowerCase();
|
|
189
|
+
const name = (element.name || '').toLowerCase();
|
|
190
|
+
const id = (element.id || '').toLowerCase();
|
|
191
|
+
const placeholder = (element.placeholder || '').toLowerCase();
|
|
192
|
+
const ariaLabel = (element.getAttribute('aria-label') || '').toLowerCase();
|
|
193
|
+
const autocomplete = (element.autocomplete || '').toLowerCase();
|
|
194
|
+
|
|
195
|
+
// Password fields
|
|
196
|
+
if (type === 'password') {
|
|
197
|
+
return { isSecret: true, fieldType: 'password' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Secret keywords
|
|
201
|
+
const secretKeywords = [
|
|
202
|
+
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
203
|
+
'api-key', 'auth', 'credential', 'private', 'ssn', 'credit', 'cvv',
|
|
204
|
+
'cvc', 'pin', 'otp', 'totp', '2fa', 'mfa'
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const allText = `${name} ${id} ${placeholder} ${ariaLabel} ${autocomplete}`;
|
|
208
|
+
|
|
209
|
+
for (const keyword of secretKeywords) {
|
|
210
|
+
if (allText.includes(keyword)) {
|
|
211
|
+
return { isSecret: true, fieldType: keyword };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { isSecret: false, fieldType: null };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function generateParamName(fieldType, element) {
|
|
219
|
+
const name = element.name || element.id || fieldType || 'secret';
|
|
220
|
+
const cleanName = name
|
|
221
|
+
.replace(/[^a-zA-Z0-9]/g, '_')
|
|
222
|
+
.replace(/_+/g, '_')
|
|
223
|
+
.replace(/^_|_$/g, '')
|
|
224
|
+
.toLowerCase();
|
|
225
|
+
|
|
226
|
+
return cleanName || 'secret';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ============================================
|
|
230
|
+
// Overlay UI
|
|
231
|
+
// ============================================
|
|
232
|
+
|
|
233
|
+
function createOverlay() {
|
|
234
|
+
if (overlay) return;
|
|
235
|
+
|
|
236
|
+
overlay = document.createElement('div');
|
|
237
|
+
overlay.id = 'chrometools-recorder-overlay';
|
|
238
|
+
overlay.innerHTML = `
|
|
239
|
+
<div class="recorder-dot"></div>
|
|
240
|
+
<span class="recorder-text">Recording <span class="action-count">${actionCount}</span></span>
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
document.body.appendChild(overlay);
|
|
244
|
+
|
|
245
|
+
// Make draggable
|
|
246
|
+
makeDraggable(overlay);
|
|
247
|
+
|
|
248
|
+
// Add recording indicator to body
|
|
249
|
+
document.body.classList.add('chrometools-recording');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function removeOverlay() {
|
|
253
|
+
if (overlay) {
|
|
254
|
+
overlay.remove();
|
|
255
|
+
overlay = null;
|
|
256
|
+
}
|
|
257
|
+
document.body.classList.remove('chrometools-recording');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function updateOverlay() {
|
|
261
|
+
if (!overlay) return;
|
|
262
|
+
const countEl = overlay.querySelector('.action-count');
|
|
263
|
+
if (countEl) {
|
|
264
|
+
countEl.textContent = actionCount;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (isPaused) {
|
|
268
|
+
overlay.classList.add('paused');
|
|
269
|
+
overlay.querySelector('.recorder-text').textContent = `Paused (${actionCount})`;
|
|
270
|
+
} else {
|
|
271
|
+
overlay.classList.remove('paused');
|
|
272
|
+
overlay.querySelector('.recorder-text').innerHTML = `Recording <span class="action-count">${actionCount}</span>`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function makeDraggable(element) {
|
|
277
|
+
let isDragging = false;
|
|
278
|
+
let offsetX, offsetY;
|
|
279
|
+
|
|
280
|
+
element.addEventListener('mousedown', (e) => {
|
|
281
|
+
isDragging = true;
|
|
282
|
+
offsetX = e.clientX - element.offsetLeft;
|
|
283
|
+
offsetY = e.clientY - element.offsetTop;
|
|
284
|
+
element.style.cursor = 'grabbing';
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
document.addEventListener('mousemove', (e) => {
|
|
288
|
+
if (!isDragging) return;
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
|
|
291
|
+
const x = e.clientX - offsetX;
|
|
292
|
+
const y = e.clientY - offsetY;
|
|
293
|
+
|
|
294
|
+
element.style.left = `${x}px`;
|
|
295
|
+
element.style.top = `${y}px`;
|
|
296
|
+
element.style.right = 'auto';
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
document.addEventListener('mouseup', () => {
|
|
300
|
+
isDragging = false;
|
|
301
|
+
if (element) {
|
|
302
|
+
element.style.cursor = 'move';
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ============================================
|
|
308
|
+
// Event Handlers
|
|
309
|
+
// ============================================
|
|
310
|
+
|
|
311
|
+
function handleClick(e) {
|
|
312
|
+
if (!isRecording || isPaused) return;
|
|
313
|
+
if (e.target.closest('#chrometools-recorder-overlay')) return;
|
|
314
|
+
|
|
315
|
+
const target = findClickableTarget(e.target);
|
|
316
|
+
if (!isElementVisible(target)) return;
|
|
317
|
+
|
|
318
|
+
const selector = generateSelector(target);
|
|
319
|
+
|
|
320
|
+
sendAction({
|
|
321
|
+
type: 'click',
|
|
322
|
+
selector,
|
|
323
|
+
timestamp: Date.now(),
|
|
324
|
+
data: {
|
|
325
|
+
text: target.textContent?.trim().substring(0, 50) || '',
|
|
326
|
+
href: target.href || null,
|
|
327
|
+
tagName: target.tagName.toLowerCase()
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
highlightElement(target);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function handleInput(e) {
|
|
335
|
+
if (!isRecording || isPaused) return;
|
|
336
|
+
if (e.target.closest('#chrometools-recorder-overlay')) return;
|
|
337
|
+
|
|
338
|
+
const element = e.target;
|
|
339
|
+
const timerId = inputDebounceTimers.get(element);
|
|
340
|
+
if (timerId) clearTimeout(timerId);
|
|
341
|
+
|
|
342
|
+
// Capture the START value when user begins typing in this element
|
|
343
|
+
// This value stays the same until we actually record the action
|
|
344
|
+
if (!inputStartValues.has(element)) {
|
|
345
|
+
inputStartValues.set(element, lastInputValues.get(element) || '');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// NO debounce timer - only record on blur/Enter/tab switch
|
|
349
|
+
// This prevents multiple recordings when user types slowly with pauses
|
|
350
|
+
// The value will be recorded when:
|
|
351
|
+
// 1. User clicks elsewhere (blur)
|
|
352
|
+
// 2. User presses Enter or Tab
|
|
353
|
+
// 3. Recording stops
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function flushAllPendingInputs() {
|
|
357
|
+
// Flush all elements that have pending input values
|
|
358
|
+
for (const element of inputStartValues.keys()) {
|
|
359
|
+
flushInputValue(element);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function flushInputValue(element) {
|
|
364
|
+
// Clear any pending timer
|
|
365
|
+
const timerId = inputDebounceTimers.get(element);
|
|
366
|
+
if (timerId) {
|
|
367
|
+
clearTimeout(timerId);
|
|
368
|
+
inputDebounceTimers.delete(element);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Get the value from when user started typing
|
|
372
|
+
const startValue = inputStartValues.get(element);
|
|
373
|
+
if (startValue === undefined) return; // No input session in progress
|
|
374
|
+
|
|
375
|
+
const finalValue = element.value;
|
|
376
|
+
|
|
377
|
+
// Only record if value actually changed
|
|
378
|
+
if (finalValue === startValue) {
|
|
379
|
+
inputStartValues.delete(element);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!isElementVisible(element)) {
|
|
384
|
+
inputStartValues.delete(element);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const selector = generateSelector(element);
|
|
389
|
+
const secretInfo = isSecretField(element);
|
|
390
|
+
|
|
391
|
+
let recordedValue = finalValue;
|
|
392
|
+
let paramName = null;
|
|
393
|
+
|
|
394
|
+
if (secretInfo.isSecret) {
|
|
395
|
+
paramName = generateParamName(secretInfo.fieldType, element);
|
|
396
|
+
recordedValue = `{{${paramName}}}`;
|
|
397
|
+
|
|
398
|
+
// Register secret with background
|
|
399
|
+
chrome.runtime.sendMessage({
|
|
400
|
+
type: 'REGISTER_SECRET',
|
|
401
|
+
paramName,
|
|
402
|
+
value: finalValue
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
sendAction({
|
|
407
|
+
type: 'type',
|
|
408
|
+
selector,
|
|
409
|
+
timestamp: Date.now(),
|
|
410
|
+
data: {
|
|
411
|
+
text: recordedValue,
|
|
412
|
+
isSecret: secretInfo.isSecret,
|
|
413
|
+
paramName,
|
|
414
|
+
clearFirst: startValue === ''
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Update stored value and clear the start value
|
|
419
|
+
lastInputValues.set(element, finalValue);
|
|
420
|
+
inputStartValues.delete(element);
|
|
421
|
+
highlightElement(element);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function handleBlur(e) {
|
|
425
|
+
if (!isRecording || isPaused) return;
|
|
426
|
+
if (e.target.closest('#chrometools-recorder-overlay')) return;
|
|
427
|
+
|
|
428
|
+
const element = e.target;
|
|
429
|
+
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
|
430
|
+
flushInputValue(element);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function handleChange(e) {
|
|
435
|
+
if (!isRecording || isPaused) return;
|
|
436
|
+
if (e.target.closest('#chrometools-recorder-overlay')) return;
|
|
437
|
+
|
|
438
|
+
const element = e.target;
|
|
439
|
+
|
|
440
|
+
if (element.tagName === 'SELECT') {
|
|
441
|
+
if (!isElementVisible(element)) return;
|
|
442
|
+
|
|
443
|
+
const selector = generateSelector(element);
|
|
444
|
+
|
|
445
|
+
sendAction({
|
|
446
|
+
type: 'select',
|
|
447
|
+
selector,
|
|
448
|
+
timestamp: Date.now(),
|
|
449
|
+
data: {
|
|
450
|
+
value: element.value,
|
|
451
|
+
text: element.options[element.selectedIndex]?.text
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
highlightElement(element);
|
|
456
|
+
} else if (element.type === 'file') {
|
|
457
|
+
if (!isElementVisible(element)) return;
|
|
458
|
+
|
|
459
|
+
const selector = generateSelector(element);
|
|
460
|
+
|
|
461
|
+
sendAction({
|
|
462
|
+
type: 'upload',
|
|
463
|
+
selector,
|
|
464
|
+
timestamp: Date.now(),
|
|
465
|
+
data: {
|
|
466
|
+
fileName: element.files[0]?.name,
|
|
467
|
+
filePath: '{{filePath}}'
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
} else if (element.type === 'checkbox' || element.type === 'radio') {
|
|
471
|
+
if (!isElementVisible(element)) return;
|
|
472
|
+
|
|
473
|
+
const selector = generateSelector(element);
|
|
474
|
+
|
|
475
|
+
sendAction({
|
|
476
|
+
type: 'click',
|
|
477
|
+
selector,
|
|
478
|
+
timestamp: Date.now(),
|
|
479
|
+
data: {
|
|
480
|
+
checked: element.checked,
|
|
481
|
+
inputType: element.type,
|
|
482
|
+
value: element.value
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
highlightElement(element);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function handleKeyDown(e) {
|
|
491
|
+
if (!isRecording || isPaused) return;
|
|
492
|
+
if (e.target.closest('#chrometools-recorder-overlay')) return;
|
|
493
|
+
|
|
494
|
+
const element = e.target;
|
|
495
|
+
|
|
496
|
+
// Flush input before Enter/Tab (these typically submit or move to next field)
|
|
497
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
498
|
+
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
|
499
|
+
flushInputValue(element);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const specialKeys = ['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete'];
|
|
504
|
+
|
|
505
|
+
if (specialKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
|
|
506
|
+
const modifiers = [];
|
|
507
|
+
if (e.ctrlKey) modifiers.push('Control');
|
|
508
|
+
if (e.shiftKey) modifiers.push('Shift');
|
|
509
|
+
if (e.altKey) modifiers.push('Alt');
|
|
510
|
+
if (e.metaKey) modifiers.push('Meta');
|
|
511
|
+
|
|
512
|
+
// Skip pure modifier keys
|
|
513
|
+
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return;
|
|
514
|
+
|
|
515
|
+
sendAction({
|
|
516
|
+
type: 'keypress',
|
|
517
|
+
selector: null,
|
|
518
|
+
timestamp: Date.now(),
|
|
519
|
+
data: {
|
|
520
|
+
key: e.key,
|
|
521
|
+
modifiers
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function handleScroll(e) {
|
|
528
|
+
if (!isRecording || isPaused) return;
|
|
529
|
+
|
|
530
|
+
if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer);
|
|
531
|
+
|
|
532
|
+
scrollDebounceTimer = setTimeout(() => {
|
|
533
|
+
const target = e.target === document ? document.documentElement : e.target;
|
|
534
|
+
if (target.nodeType !== 1) return;
|
|
535
|
+
|
|
536
|
+
const selector = generateSelector(target);
|
|
537
|
+
|
|
538
|
+
sendAction({
|
|
539
|
+
type: 'scroll',
|
|
540
|
+
selector,
|
|
541
|
+
timestamp: Date.now(),
|
|
542
|
+
data: {
|
|
543
|
+
scrollTop: target.scrollTop,
|
|
544
|
+
scrollLeft: target.scrollLeft
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}, 1000);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ============================================
|
|
551
|
+
// Helpers
|
|
552
|
+
// ============================================
|
|
553
|
+
|
|
554
|
+
function findClickableTarget(element) {
|
|
555
|
+
let current = element;
|
|
556
|
+
const maxDepth = 5;
|
|
557
|
+
let depth = 0;
|
|
558
|
+
|
|
559
|
+
while (current && current !== document.body && depth < maxDepth) {
|
|
560
|
+
const isInteractive =
|
|
561
|
+
current.tagName === 'A' ||
|
|
562
|
+
current.tagName === 'BUTTON' ||
|
|
563
|
+
current.getAttribute('role') === 'button' ||
|
|
564
|
+
current.getAttribute('role') === 'link' ||
|
|
565
|
+
current.hasAttribute('onclick') ||
|
|
566
|
+
current.onclick !== null ||
|
|
567
|
+
getComputedStyle(current).cursor === 'pointer';
|
|
568
|
+
|
|
569
|
+
if (isInteractive) {
|
|
570
|
+
return current;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
current = current.parentElement;
|
|
574
|
+
depth++;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return element;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function isElementVisible(element) {
|
|
581
|
+
if (!element) return false;
|
|
582
|
+
if (element.offsetWidth === 0 && element.offsetHeight === 0) return false;
|
|
583
|
+
|
|
584
|
+
const style = getComputedStyle(element);
|
|
585
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function highlightElement(element) {
|
|
593
|
+
element.classList.add('chrometools-highlight');
|
|
594
|
+
setTimeout(() => {
|
|
595
|
+
element.classList.remove('chrometools-highlight');
|
|
596
|
+
}, 500);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function sendAction(action) {
|
|
600
|
+
actionCount++;
|
|
601
|
+
updateOverlay();
|
|
602
|
+
|
|
603
|
+
chrome.runtime.sendMessage({
|
|
604
|
+
type: 'ACTION',
|
|
605
|
+
action
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ============================================
|
|
610
|
+
// Event Listener Management
|
|
611
|
+
// ============================================
|
|
612
|
+
|
|
613
|
+
function attachEventListeners() {
|
|
614
|
+
document.addEventListener('click', handleClick, true);
|
|
615
|
+
document.addEventListener('input', handleInput, true);
|
|
616
|
+
document.addEventListener('change', handleChange, true);
|
|
617
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
618
|
+
document.addEventListener('blur', handleBlur, true);
|
|
619
|
+
document.addEventListener('scroll', handleScroll, true);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function detachEventListeners() {
|
|
623
|
+
document.removeEventListener('click', handleClick, true);
|
|
624
|
+
document.removeEventListener('input', handleInput, true);
|
|
625
|
+
document.removeEventListener('change', handleChange, true);
|
|
626
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
627
|
+
document.removeEventListener('blur', handleBlur, true);
|
|
628
|
+
document.removeEventListener('scroll', handleScroll, true);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ============================================
|
|
632
|
+
// Message Handling
|
|
633
|
+
// ============================================
|
|
634
|
+
|
|
635
|
+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
636
|
+
switch (message.type) {
|
|
637
|
+
case 'RECORDING_STARTED':
|
|
638
|
+
isRecording = true;
|
|
639
|
+
isPaused = false;
|
|
640
|
+
// Use provided actionCount if available (e.g., when switching tabs during recording)
|
|
641
|
+
actionCount = message.actionCount || actionCount || 0;
|
|
642
|
+
createOverlay();
|
|
643
|
+
attachEventListeners();
|
|
644
|
+
sendResponse({ success: true });
|
|
645
|
+
break;
|
|
646
|
+
|
|
647
|
+
case 'RECORDING_STOPPED':
|
|
648
|
+
// Flush any pending input values before stopping
|
|
649
|
+
flushAllPendingInputs();
|
|
650
|
+
isRecording = false;
|
|
651
|
+
isPaused = false;
|
|
652
|
+
removeOverlay();
|
|
653
|
+
detachEventListeners();
|
|
654
|
+
sendResponse({ success: true });
|
|
655
|
+
break;
|
|
656
|
+
|
|
657
|
+
case 'RECORDING_PAUSED':
|
|
658
|
+
isPaused = true;
|
|
659
|
+
updateOverlay();
|
|
660
|
+
sendResponse({ success: true });
|
|
661
|
+
break;
|
|
662
|
+
|
|
663
|
+
case 'RECORDING_RESUMED':
|
|
664
|
+
isPaused = false;
|
|
665
|
+
updateOverlay();
|
|
666
|
+
sendResponse({ success: true });
|
|
667
|
+
break;
|
|
668
|
+
|
|
669
|
+
case 'GET_RECORDING_STATE':
|
|
670
|
+
sendResponse({
|
|
671
|
+
isRecording,
|
|
672
|
+
isPaused,
|
|
673
|
+
actionCount
|
|
674
|
+
});
|
|
675
|
+
break;
|
|
676
|
+
|
|
677
|
+
default:
|
|
678
|
+
sendResponse({ error: 'Unknown message type' });
|
|
679
|
+
}
|
|
680
|
+
return false;
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// ============================================
|
|
684
|
+
// Initialization
|
|
685
|
+
// ============================================
|
|
686
|
+
|
|
687
|
+
async function initialize() {
|
|
688
|
+
// Check if recording is already in progress
|
|
689
|
+
try {
|
|
690
|
+
const response = await chrome.runtime.sendMessage({ type: 'GET_RECORDING_STATE' });
|
|
691
|
+
if (response && response.isRecording) {
|
|
692
|
+
isRecording = true;
|
|
693
|
+
isPaused = response.isPaused;
|
|
694
|
+
actionCount = response.actionCount || 0;
|
|
695
|
+
|
|
696
|
+
// Wait for DOM to be ready
|
|
697
|
+
if (document.readyState === 'loading') {
|
|
698
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
699
|
+
createOverlay();
|
|
700
|
+
attachEventListeners();
|
|
701
|
+
});
|
|
702
|
+
} else {
|
|
703
|
+
createOverlay();
|
|
704
|
+
attachEventListeners();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
} catch (error) {
|
|
708
|
+
// Extension context might not be ready, that's ok
|
|
709
|
+
console.log('[ChromeTools] Content script initialized (no active recording)');
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
initialize();
|
|
714
|
+
|
|
715
|
+
})();
|