chrometools-mcp
Version:
MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, visual testing, Figma comparison, and design validation. Works seamlessly in WSL, Linux, and macOS.
1,599 lines (1,344 loc) • 49.2 kB
JavaScript
/**
* recorder/recorder-script.js
*
* Browser-side recorder implementation.
* This code is injected into the page via page.evaluate() or page.addScriptTag().
*
* Features:
* 1. Floating UI widget for recording control
* 2. Event capture (click, type, select, scroll, hover, keypress, drag, upload)
* 3. Visual highlighting of recorded elements
* 4. Real-time action list display
* 5. Secret detection and masking
* 6. Selector generation
* 7. Action debouncing
*/
import { browserSelectorGenerator } from '../utils/selector-generator.js';
import { browserSecretDetector } from './secret-detector.js';
/**
* Generate complete browser-side recorder script
* This will be injected into the page
*/
export function generateRecorderScript() {
// Note: Using template literal interpolation to inject dependencies
return `
(function() {
'use strict';
// ==========================
// RECORDER STATE
// ==========================
const state = {
isRecording: false,
isPaused: false,
actions: [],
secrets: {},
lastActionTime: 0,
isCompact: false, // Widget compact mode
startUrl: null, // URL when recording started
endUrl: null, // URL when recording ended
scenarioMetadata: {
name: '',
description: '',
tags: [],
dependencies: []
},
// Track hovers that are candidates for deletion
hoverDeletionCandidates: new Set()
};
// Track click listeners on elements (to detect if click follows hover)
const elementClickTracker = new WeakMap();
// LocalStorage key for persistence
const STORAGE_KEY = 'chrometools-recorder-state';
const CLEARING_FLAG_KEY = 'chrometools-recorder-clearing';
// ==========================
// STATE PERSISTENCE
// ==========================
function saveStateToLocalStorage() {
try {
// Check global clearing flag (across all instances)
const globalClearing = localStorage.getItem(CLEARING_FLAG_KEY);
if (globalClearing === 'true') {
return;
}
// Don't save if we're in the process of clearing
if (isClearing) {
return;
}
// Don't save if we're not recording and have no actions (clean state)
if (!state.isRecording && state.actions.length === 0) {
return; // Don't persist empty state
}
const persistentState = {
isRecording: state.isRecording,
isPaused: state.isPaused,
actions: state.actions,
secrets: state.secrets,
startUrl: state.startUrl,
endUrl: state.endUrl,
isCompact: state.isCompact,
scenarioMetadata: state.scenarioMetadata,
hoverDeletionCandidates: Array.from(state.hoverDeletionCandidates),
timestamp: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(persistentState));
} catch (error) {
console.error('[Recorder] Failed to save state:', error);
}
}
function loadStateFromLocalStorage() {
try {
// Check if we're in clearing mode
const globalClearing = localStorage.getItem(CLEARING_FLAG_KEY);
if (globalClearing === 'true') {
return null;
}
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return null;
const persistentState = JSON.parse(saved);
// Check if state is not too old (max 24 hours)
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (Date.now() - persistentState.timestamp > maxAge) {
clearStateFromLocalStorage();
resetClearingFlag();
return null;
}
return persistentState;
} catch (error) {
console.error('[Recorder] Failed to load state:', error);
return null;
}
}
function clearStateFromLocalStorage() {
try {
// Set global flag first to block all saves (PERMANENTLY - only reset on new recording start)
localStorage.setItem(CLEARING_FLAG_KEY, 'true');
// Then remove state
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('[Recorder] Failed to clear state:', error);
}
}
function resetClearingFlag() {
try {
localStorage.removeItem(CLEARING_FLAG_KEY);
} catch (error) {
console.error('[Recorder] Failed to reset clearing flag:', error);
}
}
// Flag to prevent saving after explicit clear
let isClearing = false;
function restoreState(savedState) {
state.isRecording = savedState.isRecording;
state.isPaused = savedState.isPaused;
state.actions = savedState.actions || [];
state.secrets = savedState.secrets || {};
state.startUrl = savedState.startUrl;
state.endUrl = savedState.endUrl;
state.isCompact = savedState.isCompact || false;
state.scenarioMetadata = savedState.scenarioMetadata || {
name: '',
description: '',
tags: [],
dependencies: []
};
state.hoverDeletionCandidates = new Set(savedState.hoverDeletionCandidates || []);
// Restore UI state
updateUIState();
updateActionsList();
// Restore compact mode
if (state.isCompact) {
const widget = document.getElementById('chrometools-recorder');
widget.classList.add('compact');
}
// Restore metadata form
document.getElementById('recorder-scenario-name').value = state.scenarioMetadata.name || '';
document.getElementById('recorder-scenario-desc').value = state.scenarioMetadata.description || '';
document.getElementById('recorder-scenario-tags').value = state.scenarioMetadata.tags.join(', ');
// Note: updateDependencyOptions() is called in initializeRecorder() with setTimeout
// to ensure DOM is fully ready
// If was recording, re-attach listeners
if (state.isRecording) {
attachEventListeners();
}
}
// Selector generator (injected)
${browserSelectorGenerator}
const selectorGenerator = window.selectorGenerator || {};
// Secret detector (injected)
${browserSecretDetector}
const secretDetector = window.secretDetector || {};
// ==========================
// UI COMPONENTS
// ==========================
let recorderUI = null;
let actionsList = null;
let highlightOverlay = null;
function createRecorderUI() {
// Main widget container
recorderUI = document.createElement('div');
recorderUI.id = 'chrometools-recorder';
recorderUI.innerHTML = \`
<style>
position: fixed;
top: 20px;
right: 20px;
width: 320px;
background: linear-gradient(135deg,
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: white;
transition: all 0.3s ease;
}
/* Compact mode */
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(102, 126, 234, 0.9);
cursor: pointer;
overflow: hidden;
}
display: none;
}
display: none;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
font-size: 24px;
}
display: flex;
}
content: '⏺';
color:
animation: recording-icon-pulse 2s ease-in-out infinite;
}
@keyframes recording-icon-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
padding: 16px;
cursor: move;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
width: 8px;
height: 8px;
border-radius: 50%;
background:
}
background:
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Visual indicator for active recording */
body.chrometools-recording {
outline: 3px solid rgba(239, 68, 68, 0.6) !important;
outline-offset: -3px !important;
animation: recording-pulse 2s ease-in-out infinite !important;
}
@keyframes recording-pulse {
0%, 100% {
outline-color: rgba(239, 68, 68, 0.6);
}
50% {
outline-color: rgba(239, 68, 68, 0.3);
}
}
padding: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.recorder-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
flex: 1;
min-width: 80px;
}
.recorder-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.recorder-btn-primary {
background:
color: white;
}
.recorder-btn-secondary {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.recorder-btn-danger {
background:
color: white;
}
.recorder-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
max-height: 300px;
overflow-y: auto;
padding: 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 0 0 12px 12px;
}
.recorder-action-item {
padding: 8px 12px;
margin-bottom: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.recorder-action-type {
font-weight: 600;
color:
}
.recorder-action-secret {
color:
}
.recorder-action-details {
font-size: 11px;
opacity: 0.8;
margin-top: 4px;
}
.recorder-highlight {
position: absolute;
pointer-events: none;
border: 2px solid
background: rgba(16, 185, 129, 0.1);
z-index: 999998;
transition: all 0.2s;
}
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
width: 100%;
padding: 12px 8px;
margin-bottom: 8px;
border: none;
border-radius: 6px;
font-size: 12px;
min-height: 40px;
background: rgba(255, 255, 255, 0.95);
color:
}
color:
opacity: 0.8;
}
cursor: pointer;
}
padding: 8px;
}
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-top: -4px;
margin-bottom: 8px;
padding-left: 4px;
}
.recorder-collapse {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s;
}
.recorder-collapse.expanded {
max-height: 500px;
}
</style>
<div id="chrometools-recorder-compact-icon"></div>
<div id="chrometools-recorder-header">
<div id="chrometools-recorder-title">
<div id="chrometools-recorder-status"></div>
<span>Scenario Recorder</span>
</div>
<div style="display: flex; gap: 4px;">
<button class="recorder-btn recorder-btn-secondary" id="recorder-toggle-metadata" style="flex: none; min-width: auto; padding: 4px 8px;" title="Settings">⚙️</button>
<button class="recorder-btn recorder-btn-secondary" id="recorder-toggle-compact" style="flex: none; min-width: auto; padding: 4px 8px;" title="Minimize">−</button>
</div>
</div>
<div id="chrometools-recorder-metadata" class="recorder-collapse">
<input type="text" id="recorder-scenario-name" placeholder="Scenario Name" />
<input type="text" id="recorder-scenario-desc" placeholder="Description (optional)" />
<input type="text" id="recorder-scenario-tags" placeholder="Tags (comma-separated)" />
<select id="recorder-scenario-dependency">
<option value="">No dependency</option>
</select>
<div id="recorder-dependency-info"></div>
</div>
<div id="chrometools-recorder-controls">
<button class="recorder-btn recorder-btn-primary" id="recorder-start">Start</button>
<button class="recorder-btn recorder-btn-secondary" id="recorder-pause" disabled>Pause</button>
<button class="recorder-btn recorder-btn-secondary" id="recorder-stop-only" disabled>Stop</button>
<button class="recorder-btn recorder-btn-danger" id="recorder-stop" disabled>Stop & Save</button>
<button class="recorder-btn recorder-btn-secondary" id="recorder-clear">Clear</button>
</div>
<div id="chrometools-recorder-actions">
<div style="text-align: center; opacity: 0.6; font-size: 12px;">
No actions recorded yet
</div>
</div>
\`;
// Ensure body exists before appending
if (!document.body) {
console.error('[Recorder] ERROR: document.body is null, cannot create UI');
throw new Error('Cannot create recorder UI: document.body is null');
}
document.body.appendChild(recorderUI);
// Make draggable
makeDraggable(recorderUI);
// Setup event listeners
setupUIEventListeners();
// Create highlight overlay
highlightOverlay = document.createElement('div');
highlightOverlay.className = 'recorder-highlight';
highlightOverlay.style.display = 'none';
document.body.appendChild(highlightOverlay);
// Get actions list element
actionsList = document.getElementById('chrometools-recorder-actions');
}
function setupUIEventListeners() {
document.getElementById('recorder-start').addEventListener('click', startRecording);
document.getElementById('recorder-pause').addEventListener('click', togglePause);
document.getElementById('recorder-stop-only').addEventListener('click', stopRecording);
document.getElementById('recorder-stop').addEventListener('click', stopAndSave);
document.getElementById('recorder-clear').addEventListener('click', clearActions);
document.getElementById('recorder-toggle-metadata').addEventListener('click', toggleMetadata);
document.getElementById('recorder-toggle-compact').addEventListener('click', toggleCompactMode);
// Click on compact icon to expand
document.getElementById('chrometools-recorder-compact-icon').addEventListener('click', () => {
if (state.isCompact) {
toggleCompactMode();
}
});
}
function toggleMetadata() {
const metadata = document.getElementById('chrometools-recorder-metadata');
metadata.classList.toggle('expanded');
}
function toggleCompactMode() {
state.isCompact = !state.isCompact;
const widget = document.getElementById('chrometools-recorder');
if (state.isCompact) {
widget.classList.add('compact');
} else {
widget.classList.remove('compact');
}
saveStateToLocalStorage(); // Persist compact state
}
function makeDraggable(element) {
const header = element.querySelector('#chrometools-recorder-header');
let isDragging = false;
let currentX, currentY, initialX, initialY;
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
isDragging = true;
initialX = e.clientX - element.offsetLeft;
initialY = e.clientY - element.offsetTop;
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
element.style.left = currentX + 'px';
element.style.top = currentY + 'px';
element.style.right = 'auto';
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
}
// ==========================
// RECORDING CONTROL
// ==========================
function startRecording() {
// Reset clearing flag - new recording can now save
resetClearingFlag();
isClearing = false;
state.isRecording = true;
state.isPaused = false;
state.startUrl = window.location.href; // Capture entry URL
state.endUrl = null;
updateUIState();
attachEventListeners();
saveStateToLocalStorage(); // Persist recording state
}
function togglePause() {
state.isPaused = !state.isPaused;
updateUIState();
saveStateToLocalStorage(); // Persist pause state
}
function stopRecording() {
// Stop recording without saving
state.isRecording = false;
state.isPaused = false;
updateUIState();
detachEventListeners();
clearStateFromLocalStorage(); // Clear persistence when stopping without save
}
async function stopAndSave() {
// Capture exit URL
state.endUrl = window.location.href;
// Check if scenario name is entered BEFORE stopping
const scenarioName = document.getElementById('recorder-scenario-name').value.trim();
if (!scenarioName) {
alert('Please enter a scenario name before saving!');
// Expand metadata section to show the name field
const metadata = document.getElementById('chrometools-recorder-metadata');
if (!metadata.classList.contains('expanded')) {
metadata.classList.add('expanded');
}
// Focus on name input
document.getElementById('recorder-scenario-name').focus();
// Load and update dependency options
await updateDependencyOptions();
return; // Don't stop recording yet!
}
// Only stop recording if name is provided
state.isRecording = false;
state.isPaused = false;
updateUIState();
detachEventListeners();
// Clean up useless hovers before saving
cleanupUselessHovers();
await saveScenario();
}
// Update dependency dropdown with matching scenarios
async function updateDependencyOptions() {
try {
// Request scenario list from MCP server
if (!window.listScenariosFromMCP) {
console.warn('MCP listScenarios function not available');
return;
}
const result = await window.listScenariosFromMCP();
if (!result.success || !result.scenarios) {
console.warn('Failed to load scenarios for dependency matching');
return;
}
const scenarios = result.scenarios;
const select = document.getElementById('recorder-scenario-dependency');
const infoDiv = document.getElementById('recorder-dependency-info');
// Clear existing options except first
select.innerHTML = '<option value="">No dependency</option>';
infoDiv.textContent = '';
// Show all scenarios (no filtering)
if (scenarios.length > 0) {
scenarios.forEach(scenario => {
const option = document.createElement('option');
option.value = scenario.name;
// Show scenario name and exit URL if available
const exitUrlText = scenario.metadata?.exitUrl ? \` → \${scenario.metadata.exitUrl}\` : '';
option.textContent = \`\${scenario.name}\${exitUrlText}\`;
select.appendChild(option);
});
infoDiv.textContent = \`\${scenarios.length} scenario(s) available\`;
} else {
infoDiv.textContent = 'No scenarios found';
}
} catch (error) {
console.error('Error updating dependency options:', error);
}
}
// Remove hovers that are still marked as deletion candidates
function cleanupUselessHovers() {
if (state.hoverDeletionCandidates.size === 0) {
return; // Nothing to clean
}
// Filter out hovers that are deletion candidates
const indicesToDelete = Array.from(state.hoverDeletionCandidates).sort((a, b) => b - a);
indicesToDelete.forEach(index => {
if (index < state.actions.length && state.actions[index]?.type === 'hover') {
state.actions.splice(index, 1);
}
});
// Clear the set
state.hoverDeletionCandidates.clear();
console.log(\`Cleaned up \${indicesToDelete.length} useless hover events\`);
}
function clearActions() {
state.actions = [];
state.secrets = {};
state.hoverDeletionCandidates.clear();
updateActionsList();
saveStateToLocalStorage(); // Persist cleared state
}
function updateUIState() {
const status = document.getElementById('chrometools-recorder-status');
const startBtn = document.getElementById('recorder-start');
const pauseBtn = document.getElementById('recorder-pause');
const stopOnlyBtn = document.getElementById('recorder-stop-only');
const stopBtn = document.getElementById('recorder-stop');
if (state.isRecording) {
status.classList.add('recording');
startBtn.disabled = true;
pauseBtn.disabled = false;
stopOnlyBtn.disabled = false;
stopBtn.disabled = false;
pauseBtn.textContent = state.isPaused ? 'Resume' : 'Pause';
// Add visual indicator to body
document.body.classList.add('chrometools-recording');
} else {
status.classList.remove('recording');
startBtn.disabled = false;
pauseBtn.disabled = true;
stopOnlyBtn.disabled = true;
stopBtn.disabled = true;
// Remove visual indicator from body
document.body.classList.remove('chrometools-recording');
}
}
// ==========================
// EVENT LISTENERS
// ==========================
function attachEventListeners() {
document.addEventListener('click', handleClick, true);
document.addEventListener('input', handleInput, true);
document.addEventListener('change', handleChange, true);
document.addEventListener('scroll', handleScroll, true);
document.addEventListener('mouseover', handleMouseOver, true);
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener('dragstart', handleDragStart, true);
document.addEventListener('dragend', handleDragEnd, true);
}
function detachEventListeners() {
document.removeEventListener('click', handleClick, true);
document.removeEventListener('input', handleInput, true);
document.removeEventListener('change', handleChange, true);
document.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mouseover', handleMouseOver, true);
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener('dragstart', handleDragStart, true);
document.removeEventListener('dragend', handleDragEnd, true);
hideHighlight();
}
// ==========================
// EVENT HANDLERS
// ==========================
function handleClick(e) {
if (!shouldRecordEvent(e)) return;
// Check if this is part of recorder UI
if (e.target.closest('#chrometools-recorder')) {
return;
}
// DO NOT prevent default - let clicks work normally
// Just record them without blocking
// Find the actual clickable element (element with click listener or interactive role)
const actualTarget = findActualClickTarget(e.target);
const selectorInfo = selectorGenerator.generateSelectorForElement(actualTarget);
const form = actualTarget.closest('form');
// Mark any hovers on this element or parent chain as NOT candidates for deletion
// (because click happened after hover - it's a meaningful hover)
markHoversAsUseful(actualTarget);
// Setup click tracking on this element (for future hover filtering)
setupClickTracking(actualTarget);
recordAction({
type: 'click',
selector: selectorInfo,
timestamp: Date.now(),
data: {
text: actualTarget.textContent?.trim().substring(0, 50) || '',
href: actualTarget.href || null,
requiresWait: true // Flag for smart waiting during playback
}
});
highlightElement(actualTarget);
}
// Setup click tracking on element to detect future clicks
function setupClickTracking(element) {
// Check if already has our tracking listener
if (elementClickTracker.has(element)) {
return; // Already tracked
}
// Create a capture listener that marks hovers as useful
const clickHandler = function(e) {
// Mark any recent hovers on this element as useful
markHoversAsUseful(element);
};
// Add listener in capture phase (before any other handlers)
element.addEventListener('click', clickHandler, { capture: true, passive: true });
// Track that we added the listener
elementClickTracker.set(element, clickHandler);
}
// Mark hovers on this element (and parents) as NOT candidates for deletion
function markHoversAsUseful(element) {
// Look through recent actions and remove hovers from deletion candidates
const selector = selectorGenerator.generateSelectorForElement(element);
const primarySelector = selector.primary;
// Also check parent selectors (for bubbling)
let current = element;
const selectorsToCheck = [primarySelector];
// Collect parent selectors
for (let i = 0; i < 3 && current.parentElement; i++) {
current = current.parentElement;
const parentSelector = selectorGenerator.generateSelectorForElement(current);
selectorsToCheck.push(parentSelector.primary);
}
// Remove matching hovers from deletion candidates
state.hoverDeletionCandidates.forEach(actionIndex => {
const action = state.actions[actionIndex];
if (action && action.type === 'hover') {
const hoverSelector = action.selector.primary || action.selector.value;
if (selectorsToCheck.includes(hoverSelector)) {
state.hoverDeletionCandidates.delete(actionIndex);
}
}
});
}
// Find the actual clickable element by looking for click listeners
function findActualClickTarget(element) {
let current = element;
const maxDepth = 5; // Don't go too far up the tree
let depth = 0;
while (current && current !== document.body && depth < maxDepth) {
// Check if this element has event listeners
const hasClickListener = hasEventListener(current, 'click');
// Check if element is naturally interactive
const isInteractive = current.tagName === 'A' ||
current.tagName === 'BUTTON' ||
current.getAttribute('role') === 'button' ||
current.getAttribute('role') === 'link' ||
current.hasAttribute('onclick') ||
current.style.cursor === 'pointer';
if (hasClickListener || isInteractive) {
return current;
}
current = current.parentElement;
depth++;
}
// If no clickable parent found, return original element
return element;
}
// Check if element has specific event listener
function hasEventListener(element, eventType) {
// Try to detect listeners through getEventListeners (Chrome DevTools API)
if (typeof getEventListeners === 'function') {
try {
const listeners = getEventListeners(element);
return listeners && listeners[eventType] && listeners[eventType].length > 0;
} catch (e) {
// getEventListeners not available
}
}
// Fallback: check common indicators
// Check onclick attribute
if (element.hasAttribute('onclick')) return true;
// Check for common event handler properties
if (element.onclick) return true;
// Check if element has data attributes that suggest it's interactive
if (element.hasAttribute('data-action') ||
element.hasAttribute('data-click') ||
element.hasAttribute('data-toggle')) {
return true;
}
return false;
}
let lastInputValue = new Map();
let inputDebounceTimers = new Map();
function handleInput(e) {
if (!shouldRecordEvent(e)) return;
if (e.target.closest('#chrometools-recorder')) return;
const element = e.target;
const form = element.closest('form');
// Debounce typing
const timerId = inputDebounceTimers.get(element);
if (timerId) clearTimeout(timerId);
inputDebounceTimers.set(element, setTimeout(() => {
const value = element.value;
const previousValue = lastInputValue.get(element) || '';
// Only record if value changed
if (value !== previousValue) {
const selectorInfo = selectorGenerator.generateSelectorForElement(element);
// Detect if secret
const secretInfo = secretDetector.detectSecretField(element);
let recordedValue = value;
let isSecret = secretInfo.isSecret;
let paramName = null;
if (isSecret) {
paramName = secretDetector.generateParameterName(secretInfo.fieldType, element);
state.secrets[paramName] = value;
recordedValue = \`{{\${paramName}}}\`;
}
recordAction({
type: 'type',
selector: selectorInfo,
timestamp: Date.now(),
data: {
text: recordedValue,
isSecret,
paramName,
clearFirst: previousValue === '' // Clear if field was empty
}
});
lastInputValue.set(element, value);
highlightElement(element);
}
}, 500)); // 500ms debounce
}
function handleChange(e) {
if (!shouldRecordEvent(e)) return;
if (e.target.closest('#chrometools-recorder')) return;
const element = e.target;
if (element.tagName === 'SELECT') {
const selectorInfo = selectorGenerator.generateSelectorForElement(element);
recordAction({
type: 'select',
selector: selectorInfo,
timestamp: Date.now(),
data: {
value: element.value,
text: element.options[element.selectedIndex]?.text,
selectType: 'native'
}
});
highlightElement(element);
} else if (element.type === 'file') {
// File upload
const selectorInfo = selectorGenerator.generateSelectorForElement(element);
recordAction({
type: 'upload',
selector: selectorInfo,
timestamp: Date.now(),
data: {
fileName: element.files[0]?.name,
// Note: actual file path needs to be set during playback
filePath: '{{filePath}}'
}
});
}
}
let scrollDebounceTimer = null;
function handleScroll(e) {
if (!shouldRecordEvent(e)) return;
clearTimeout(scrollDebounceTimer);
scrollDebounceTimer = setTimeout(() => {
const target = e.target === document ? document.documentElement : e.target;
if (target.nodeType !== 1) return; // Only elements
const selectorInfo = selectorGenerator.generateSelectorForElement(target);
recordAction({
type: 'scroll',
selector: selectorInfo,
timestamp: Date.now(),
data: {
scrollTop: target.scrollTop,
scrollLeft: target.scrollLeft
}
});
}, 1000);
}
let lastHoverTarget = null;
function handleMouseOver(e) {
if (!shouldRecordEvent(e)) return;
if (e.target.closest('#chrometools-recorder')) return;
// Show highlight during recording
if (state.isRecording && !state.isPaused) {
showHighlight(e.target);
}
// Only record intentional hovers (elements with :hover effects)
if (e.target !== lastHoverTarget) {
const hasHoverEffect = hasHoverStyle(e.target);
if (hasHoverEffect) {
const selectorInfo = selectorGenerator.generateSelectorForElement(e.target);
// Record hover action
const actionIndex = state.actions.length;
recordAction({
type: 'hover',
selector: selectorInfo,
timestamp: Date.now(),
data: {}
});
// Mark this hover as a candidate for deletion
// It will be removed from candidates if a click happens on same/child element
state.hoverDeletionCandidates.add(actionIndex);
}
lastHoverTarget = e.target;
}
}
function hasHoverStyle(element) {
// Check if element has CSS :hover pseudo-class rules
const sheets = document.styleSheets;
const selector = selectorGenerator.generateSelectorForElement(element).primary;
for (const sheet of sheets) {
try {
const rules = sheet.cssRules || sheet.rules;
for (const rule of rules) {
if (rule.selectorText && rule.selectorText.includes(':hover')) {
if (element.matches(rule.selectorText.replace(':hover', ''))) {
return true;
}
}
}
} catch (e) {
// Cross-origin stylesheet
}
}
return false;
}
function handleKeyDown(e) {
if (!shouldRecordEvent(e)) return;
if (e.target.closest('#chrometools-recorder')) return;
// Only record special keys (not regular typing)
const specialKeys = ['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
if (specialKeys.includes(e.key)) {
const modifiers = [];
if (e.ctrlKey) modifiers.push('Control');
if (e.shiftKey) modifiers.push('Shift');
if (e.altKey) modifiers.push('Alt');
if (e.metaKey) modifiers.push('Meta');
recordAction({
type: 'keypress',
selector: null,
timestamp: Date.now(),
data: {
key: e.key,
modifiers
}
});
}
}
let dragStartInfo = null;
function handleDragStart(e) {
if (!shouldRecordEvent(e)) return;
dragStartInfo = {
element: e.target,
selector: selectorGenerator.generateSelectorForElement(e.target),
x: e.clientX,
y: e.clientY
};
}
function handleDragEnd(e) {
if (!shouldRecordEvent(e)) return;
if (!dragStartInfo) return;
recordAction({
type: 'drag',
selector: dragStartInfo.selector,
timestamp: Date.now(),
data: {
fromX: dragStartInfo.x,
fromY: dragStartInfo.y,
toX: e.clientX,
toY: e.clientY
}
});
dragStartInfo = null;
}
// ==========================
// HELPER FUNCTIONS
// ==========================
function shouldRecordEvent(e) {
return state.isRecording && !state.isPaused;
}
function recordAction(action) {
state.actions.push(action);
updateActionsList();
saveStateToLocalStorage(); // Persist after each action
}
function updateActionsList() {
if (state.actions.length === 0) {
actionsList.innerHTML = '<div style="text-align: center; opacity: 0.6; font-size: 12px;">No actions recorded yet</div>';
return;
}
actionsList.innerHTML = state.actions.map((action, index) => {
const isSecret = action.data?.isSecret;
const details = formatActionDetails(action);
return \`
<div class="recorder-action-item">
<div>
<div>
<span class="recorder-action-type">\${action.type}</span>
\${isSecret ? '<span class="recorder-action-secret">🔒</span>' : ''}
</div>
\${details ? \`<div class="recorder-action-details">\${details}</div>\` : ''}
</div>
<div>\${index + 1}</div>
</div>
\`;
}).join('');
// Scroll to bottom
actionsList.scrollTop = actionsList.scrollHeight;
}
function formatActionDetails(action) {
switch (action.type) {
case 'click':
return action.data.text || action.data.href || action.selector.primary;
case 'type':
return action.data.isSecret ? '***' : action.data.text.substring(0, 20);
case 'select':
return action.data.text || action.data.value;
case 'keypress':
return action.data.modifiers.concat(action.data.key).join('+');
default:
return '';
}
}
function showHighlight(element) {
const rect = element.getBoundingClientRect();
highlightOverlay.style.display = 'block';
highlightOverlay.style.left = (rect.left + window.scrollX) + 'px';
highlightOverlay.style.top = (rect.top + window.scrollY) + 'px';
highlightOverlay.style.width = rect.width + 'px';
highlightOverlay.style.height = rect.height + 'px';
}
function hideHighlight() {
highlightOverlay.style.display = 'none';
}
function highlightElement(element) {
showHighlight(element);
setTimeout(hideHighlight, 1000);
}
// ==========================
// SAVE SCENARIO
// ==========================
async function saveScenario() {
const scenarioName = document.getElementById('recorder-scenario-name').value.trim();
if (!scenarioName) {
alert('Please enter a scenario name');
return;
}
// Get selected dependency
const dependencySelect = document.getElementById('recorder-scenario-dependency');
const selectedDependency = dependencySelect.value;
const dependencies = selectedDependency ? [{ scenario: selectedDependency }] : [];
const metadata = {
name: scenarioName,
description: document.getElementById('recorder-scenario-desc').value.trim(),
tags: document.getElementById('recorder-scenario-tags').value.split(',').map(t => t.trim()).filter(Boolean),
dependencies,
parameters: extractParameters(),
outputs: [],
entryUrl: state.startUrl, // URL when recording started
exitUrl: state.endUrl // URL when recording stopped
};
// Optimize actions before saving
const optimizedActions = optimizeActions(state.actions);
const scenario = {
name: scenarioName,
metadata,
chain: optimizedActions,
secrets: state.secrets
};
// Call MCP server via exposed function
if (window.saveScenarioToMCP) {
try {
const result = await window.saveScenarioToMCP(scenario);
if (result.success) {
// Set clearing flag to prevent any saves during cleanup
isClearing = true;
alert(\`Scenario "\${scenarioName}" saved successfully!\`);
// Clear state
state.isRecording = false;
state.isPaused = false;
state.actions = [];
state.secrets = {};
state.hoverDeletionCandidates.clear();
state.startUrl = null;
state.endUrl = null;
state.scenarioMetadata = {
name: '',
description: '',
tags: [],
dependencies: []
};
// Clear UI
updateUIState();
updateActionsList();
document.getElementById('recorder-scenario-name').value = '';
document.getElementById('recorder-scenario-desc').value = '';
document.getElementById('recorder-scenario-tags').value = '';
// Clear localStorage (flag stays set until new recording starts)
clearStateFromLocalStorage();
// Keep clearing for a bit longer to catch any delayed saves from other instances
setTimeout(() => {
clearStateFromLocalStorage(); // Double clear after 200ms
}, 200);
setTimeout(() => {
clearStateFromLocalStorage(); // Triple clear after 500ms
isClearing = false;
}, 500);
} else {
alert(\`Error saving scenario: \${result.error}\`);
}
} catch (error) {
alert(\`Error: \${error.message}\`);
}
} else {
console.error('MCP save function not available');
alert('MCP server connection not available');
}
}
// ==========================
// ACTION OPTIMIZER (browser-side)
// ==========================
function optimizeActions(actions) {
if (!actions || actions.length === 0) {
return [];
}
let optimized = [...actions];
// Remove recorder widget events
optimized = optimized.filter(action => {
const selector = action.selector?.primary || action.selector?.value || '';
if (selector.includes('chrometools-recorder')) {
return false;
}
const classes = action.selector?.elementInfo?.classes || [];
if (classes.some(cls => cls.includes('chrometools-recorder'))) {
return false;
}
return true;
});
// Combine sequential type actions on same element
const combinedTypes = [];
let i = 0;
while (i < optimized.length) {
const action = optimized[i];
if (action.type === 'type') {
// Collect all sequential type actions on same selector
const typeActions = [action];
let j = i + 1;
while (j < optimized.length &&
optimized[j].type === 'type' &&
optimized[j].selector?.primary === action.selector?.primary) {
typeActions.push(optimized[j]);
j++;
}
// Combine if multiple type actions found
if (typeActions.length > 1) {
// Use the LAST type action as base (has correct secret info)
const lastType = typeActions[typeActions.length - 1];
combinedTypes.push(lastType);
i = j;
} else {
combinedTypes.push(action);
i++;
}
} else {
combinedTypes.push(action);
i++;
}
}
optimized = combinedTypes;
// Remove hover immediately before click on same element
optimized = optimized.filter((action, index) => {
if (action.type !== 'hover') return true;
// Look for next non-hover action
let nextIndex = index + 1;
while (nextIndex < optimized.length && optimized[nextIndex].type === 'hover') {
nextIndex++;
}
const nextAction = optimized[nextIndex];
if (!nextAction) return true;
if (nextAction.type === 'click') {
const sameElement = action.selector?.primary === nextAction.selector?.primary;
if (sameElement) {
return false; // Remove this hover
}
}
return true;
});
// Remove duplicate consecutive hovers
const dedupHovers = [];
let lastHover = null;
for (const action of optimized) {
if (action.type === 'hover') {
if (lastHover && lastHover.selector?.primary === action.selector?.primary) {
continue; // Skip duplicate
}
lastHover = action;
}
dedupHovers.push(action);
}
optimized = dedupHovers;
// Remove duplicate consecutive scrolls on same element
const dedupScrolls = [];
let lastScroll = null;
for (const action of optimized) {
if (action.type === 'scroll') {
if (lastScroll && lastScroll.selector?.primary === action.selector?.primary) {
// Replace previous scroll with current (keep latest position)
dedupScrolls.pop();
}
lastScroll = action;
}
dedupScrolls.push(action);
}
return dedupScrolls;
}
function extractParameters() {
const params = {};
// Extract parameters from secrets
for (const [paramName, value] of Object.entries(state.secrets)) {
params[paramName] = {
type: 'string',
required: true,
description: \`Secret parameter: \${paramName}\`
};
}
return params;
}
// ==========================
// INITIALIZATION
// ==========================
// Strict check: if recorder instance AND widget both exist, return existing instance
if (window.__chrometoolsRecorderInstance && document.getElementById('chrometools-recorder')) {
return window.__chrometoolsRecorderInstance;
}
// If instance exists but widget is missing (page navigation), clean up and reinitialize
if (window.__chrometoolsRecorderInstance && !document.getElementById('chrometools-recorder')) {
delete window.__chrometoolsRecorderInstance;
}
// If widget exists but instance was lost (shouldn't happen, but handle it)
if (!window.__chrometoolsRecorderInstance && document.getElementById('chrometools-recorder')) {
const existingWidget = document.getElementById('chrometools-recorder');
existingWidget.remove();
const existingHighlight = document.querySelector('.recorder-highlight');
if (existingHighlight) existingHighlight.remove();
}
// Initialize recorder UI
function initializeRecorder() {
createRecorderUI();
console.log('✅ Chrometools Recorder initialized');
// Try to restore previous recording session
const savedState = loadStateFromLocalStorage();
if (savedState) {
restoreState(savedState);
} else {
// No saved state but clearing flag might be set - reset it for fresh start
const clearingFlag = localStorage.getItem(CLEARING_FLAG_KEY);
if (clearingFlag === 'true') {
resetClearingFlag();
}
}
// Update dependency options after UI is fully initialized
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
updateDependencyOptions().catch(err => {
console.error('[Recorder] Failed to update dependencies on init:', err);
});
}, 100);
// Return control interface and store globally
const controlInterface = {
start: startRecording,
stop: stopAndSave,
pause: togglePause,
clear: clearActions,
getState: () => ({ ...state }),
getActions: () => [...state.actions]
};
// Store instance globally to prevent duplicates
window.__chrometoolsRecorderInstance = controlInterface;
return controlInterface;
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
return initializeRecorder();
});
} else {
// DOM already ready
return initializeRecorder();
}
})();
`;
}
/**
* Inject recorder into page
* @param {Object} page - Puppeteer page instance
*/
export async function injectRecorder(page) {
try {
// Check if recorder is already injected
const alreadyInjected = await page.evaluate(() => {
return document.getElementById('chrometools-recorder') !== null;
});
if (alreadyInjected) {
// Remove old recorder UI before re-injecting
await page.evaluate(() => {
const oldRecorder = document.getElementById('chrometools-recorder');
if (oldRecorder) oldRecorder.remove();
const oldHighlight = document.querySelector('.recorder-highlight');
if (oldHighlight) oldHighlight.remove();
// Remove body class
document.body.classList.remove('chrometools-recording');
});
}
// Check if function already exists
const functionExists = await page.evaluate(() => {
return typeof window.saveScenarioToMCP === 'function';
});
// Only expose functions if they don't exist yet
if (!functionExists) {
await page.exposeFunction('saveScenarioToMCP', async (scenarioData) => {
const { saveScenario } = await import('./scenario-storage.js');
return await saveScenario(scenarioData);
});
await page.exposeFunction('listScenariosFromMCP', async () => {
const { loadIndex } = await import('./scenario-storage.js');
try {
const index = await loadIndex();
return {
success: true,
scenarios: Object.values(index)
};
} catch (error) {
return {
success: false,
error: error.message,
scenarios: []
};
}
});
}
// Inject recorder script immediately into current page
await page.evaluate(generateRecorderScript());
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
}
}