diff --git a/extension-kapture/KaptureDevToolsPanel.webp b/extension-kapture/KaptureDevToolsPanel.webp new file mode 100644 index 0000000..9075392 Binary files /dev/null and b/extension-kapture/KaptureDevToolsPanel.webp differ diff --git a/extension-kapture/PRIVACY_POLICY.md b/extension-kapture/PRIVACY_POLICY.md new file mode 100644 index 0000000..116dfa5 --- /dev/null +++ b/extension-kapture/PRIVACY_POLICY.md @@ -0,0 +1,87 @@ +# Privacy Policy for Kapture Browser Automation + +**Last Updated: December 2024** + +## Overview + +Kapture Browser Automation ("the Extension") is designed to enable local browser automation through the Model Context Protocol (MCP). This privacy policy explains how the Extension handles user data. + +## Data Collection and Usage + +### What We Access +The Extension may access: +- Current tab URL and title +- Page content (HTML/DOM) when requested +- Browser console logs when requested +- Screenshots of the current tab when requested +- Form field values when automating interactions + +### What We DON'T Do +- We do NOT collect or store any personal information +- We do NOT send any data to external servers +- We do NOT track your browsing history +- We do NOT use analytics or tracking services +- We do NOT sell or share any data + +### Local Operation Only +All Extension operations are strictly local: +- Communication occurs only with localhost (127.0.0.1) on port 61822 +- Data is transmitted only to the MCP server running on your local machine +- No internet connection is required for Extension operation +- No data leaves your computer + +## Data Transmission + +When you use the Extension: +1. Commands are received from the local MCP server via WebSocket +2. The Extension executes the requested action on the current web page +3. Results are sent back to the local MCP server +4. All communication uses localhost networking only + +## Data Storage + +The Extension stores: +- Your connection status (connected/disconnected) +- A temporary session Tab ID for the current browser tab +- Command history for the current session (cleared on disconnect) + +No persistent data is stored between browser sessions. + +## Permissions + +The Extension requires certain Chrome permissions: +- `tabs`: To access tab information and execute scripts +- `debugger`: To capture screenshots +- ``: To interact with any website you choose to automate + +These permissions are used solely for providing automation functionality and are never used for tracking or data collection. + +## Security + +- All communication uses WebSocket protocol over localhost +- No authentication tokens or passwords are stored +- Each tab receives a unique session ID that expires on disconnect + +## User Control + +You have complete control: +- Connect/disconnect at any time via the DevTools panel +- Close the DevTools panel to stop all Extension activity +- Disable or uninstall the Extension at any time via Chrome settings + +## Children's Privacy + +This Extension is not directed at children under 13 and does not knowingly collect information from children. + +## Changes to This Policy + +We may update this privacy policy from time to time. Any changes will be reflected in the "Last Updated" date above. + +## Contact + +For questions about this privacy policy or the Extension, please visit: +https://github.com/williamkapke/kapture + +## Consent + +By using the Kapture Browser Automation Extension, you consent to this privacy policy. diff --git a/extension-kapture/ScreenshotWithExtensionPanel.webp b/extension-kapture/ScreenshotWithExtensionPanel.webp new file mode 100644 index 0000000..b73b5a7 Binary files /dev/null and b/extension-kapture/ScreenshotWithExtensionPanel.webp differ diff --git a/extension-kapture/background.js b/extension-kapture/background.js new file mode 100644 index 0000000..ff567b1 --- /dev/null +++ b/extension-kapture/background.js @@ -0,0 +1,239 @@ +// Background service worker - manages WebSocket connections + +import { TabManager } from './modules/tab-manager.js'; +import {ConsoleLogEntry} from "./modules/models.js"; + +// Single source of truth for all tab state +const tabManager = new TabManager(); + +// Helper function to send connection state to content script +function sendConnectionStateToTab(tabId, connectionState) { + chrome.tabs.sendMessage(tabId, { + command: '_connectionStateChanged', + params: { + status: connectionState.status, + connected: connectionState.connected + } + }).catch(err => { + // Content script might not be injected yet, ignore error + console.debug('Could not send connection state to tab:', err); + }); +} + +// Listen for tab state changes +tabManager.addListener((tabId, event, tabState, data) => { + switch (event) { + case 'stateChanged': + // Broadcast to all ports for this tab + tabState.broadcastToPorts({ + type: 'state', + tabId, + ...tabState.getConnectionState() + }); + + // Send connection state to content script + const connectionState = tabState.getConnectionState(); + sendConnectionStateToTab(tabId, connectionState); + + // Update action badge for active tab + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]?.id === tabId) { + updateActionBadge(tabState.getConnectionState()); + } + }); + break; + + case 'messageReceived': + case 'messageSent': + // Broadcast updated messages to all ports + tabState.broadcastToPorts({ + type: 'messages', + tabId, + messages: tabState.getMessages() + }); + break; + + case 'consoleLogAdded': + // Broadcast updated console count + tabState.broadcastToPorts({ + type: 'consoleCount', + tabId, + count: tabState.getConsoleLogCount() + }); + break; + + case 'messagesCleared': + tabState.broadcastToPorts({ + type: 'messages', + tabId, + messages: [] + }); + break; + + case 'consoleLogsCleared': + tabState.broadcastToPorts({ + type: 'consoleCount', + tabId, + count: 0 + }); + break; + } +}); + +// Update action badge based on connection status +function updateActionBadge(state) { + switch (state.status) { + case 'connected': + chrome.action.setBadgeText({ text: '✓' }); + chrome.action.setBadgeBackgroundColor({ color: '#4caf50' }); + break; + + case 'retrying': + chrome.action.setBadgeText({ text: '↻' }); + chrome.action.setBadgeBackgroundColor({ color: '#ff9800' }); + break; + + case 'disconnected': + default: + chrome.action.setBadgeText({ text: '' }); + break; + } +} + +// Handle UI connections +chrome.runtime.onConnect.addListener((port) => { + port.onMessage.addListener((msg) => { + if (msg.type === 'subscribe' && msg.tabId) { + tabManager.addPort(msg.tabId, port); + } else if (msg.type === 'clearMessages' && msg.tabId) { + tabManager.clearMessages(msg.tabId); + } else if (msg.type === 'clearConsoleLogs' && msg.tabId) { + tabManager.clearConsoleLogs(msg.tabId); + } + }); + + port.onDisconnect.addListener(() => { + // Remove port from all tabs + tabManager.getAllTabs().forEach(tabState => { + tabState.removePort(port); + }); + }); +}); + +// Handle all messages +chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => { + // Handle content script messages + if (sender.tab) { + if (request.type === 'contentScriptReady') { + console.log(`Content script ready in tab ${sender.tab.id}`); + + // Send current connection state to the newly ready content script + const tabState = tabManager.getTab(sender.tab.id); + if (tabState) { + const connectionState = tabState.getConnectionState(); + sendConnectionStateToTab(sender.tab.id, connectionState); + } + + sendResponse({ acknowledged: true }); + return false; + } + + if (request.type === 'connect') { + const result = await tabManager.connect(sender.tab.id); + sendResponse(result); + return true; // Keep message channel open for async response + } + + if (request.type === 'disconnect') { + const result = tabManager.disconnect(sender.tab.id); + sendResponse(result); + return false; + } + + if (request.type === 'openPopup') { + chrome.action.openPopup(); + sendResponse({ ok: true }); + return false; + } + + if (request.type === 'mousePosition') { + // Store mouse position for the tab + const tabState = tabManager.getTab(sender.tab.id); + if (tabState) { + tabState.setMousePosition({ x: request.x, y: request.y }); + } + return false; + } + + if (request.type === 'consoleLog') { + // Handle console log from content script + const tabState = tabManager.getTab(sender.tab.id); + if (tabState) { + if (request.level === 'clear') { + // Clear console logs + tabManager.clearConsoleLogs(sender.tab.id); + } else { + // Add console log + tabManager.addConsoleLog(sender.tab.id, new ConsoleLogEntry( + request.level, + request.args, + request.stackTrace + )); + } + } + return false; + } + } + + // Handle messages from popup/panel (not from content scripts) + if (!sender.tab) { + if (request.type === 'connect' && request.tabId) { + const result = await tabManager.connect(request.tabId); + sendResponse(result); + return true; // Keep message channel open for async response + } + + if (request.type === 'disconnect' && request.tabId) { + const result = tabManager.disconnect(request.tabId); + sendResponse(result); + return false; + } + + if (request.type === 'getState' && request.tabId) { + const tabState = tabManager.getTab(request.tabId); + sendResponse(tabState ? tabState.getConnectionState() : { connected: false, status: 'disconnected' }); + return false; + } + } +}); + +// Update badge when active tab changes +chrome.tabs.onActivated.addListener((activeInfo) => { + const tabState = tabManager.getTab(activeInfo.tabId); + if (tabState) { + updateActionBadge(tabState.getConnectionState()); + } else { + updateActionBadge({ status: 'disconnected' }); + } +}); + +// Clean up when tabs are closed +chrome.tabs.onRemoved.addListener((tabId) => { + tabManager.removeTab(tabId); +}); + +chrome.runtime.onInstalled.addListener((details) => { + if (details.reason === 'install') { + const destination = { url: 'https://to.kap.co/kapture-welcome' }; + // Navigate the current active tab instead of creating a new one + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + chrome.tabs.update(tabs[0].id, destination); + } + else { + // Fallback: create a new tab if no active tab is found + chrome.tabs.create(destination); + } + }); + } +}); diff --git a/extension-kapture/console-listener.js b/extension-kapture/console-listener.js new file mode 100644 index 0000000..1ffca9c --- /dev/null +++ b/extension-kapture/console-listener.js @@ -0,0 +1,88 @@ +(function() { + const isOverridden = !console.log.toString().includes('[native code]'); + + // Check if already injected + if (isOverridden) { + return; + } + + // Store original console methods + const originalConsole = { + log: console.log, + error: console.error, + warn: console.warn, + info: console.info, + debug: console.debug, + trace: console.trace, + table: console.table, + group: console.group, + groupCollapsed: console.groupCollapsed, + groupEnd: console.groupEnd, + clear: console.clear + }; + + // Helper to serialize arguments + function serializeArgs(args) { + return Array.from(args).map(arg => { + try { + if (arg === undefined) return 'undefined'; + if (arg === null) return 'null'; + if (typeof arg === 'function') return arg.toString(); + if (typeof arg === 'object') { + // Handle circular references + const seen = new WeakSet(); + return JSON.stringify(arg, function(key, value) { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]'; + seen.add(value); + } + if (typeof value === 'function') return value.toString(); + return value; + }); + } + return String(arg); + } catch (e) { + return String(arg); + } + }); + } + + // Override console methods (all except clear) + ['log', 'error', 'warn', 'info', 'debug', 'trace', 'table', 'group', 'groupCollapsed', 'groupEnd'].forEach(level => { + console[level] = function(...args) { + // Create log entry + const detail = { + level: level, + args: serializeArgs(args), + timestamp: new Date().toISOString() + }; + + // Only include stack trace for errors and warnings + if (level === 'error' || level === 'warn' || level === 'trace') { + detail.stack = new Error().stack; + } + + const event = new CustomEvent('kapture-console', { detail }); + + // Dispatch event for content script to capture + window.dispatchEvent(event); + + // Call original method + originalConsole[level].apply(console, args); + }; + }); + + // Override console.clear + console.clear = function() { + // Dispatch clear event + const event = new CustomEvent('kapture-console', { detail: { level: 'clear' }}); + originalConsole.log('[Kapture] Dispatching console clear event'); + window.dispatchEvent(event); + + // Call original method + originalConsole.clear.apply(console); + }; + + // Log that injection is complete + originalConsole.log('[Kapture] Console listener attached'); +})(); diff --git a/extension-kapture/content-script.js b/extension-kapture/content-script.js new file mode 100644 index 0000000..67d6b86 --- /dev/null +++ b/extension-kapture/content-script.js @@ -0,0 +1,30 @@ +// Proxy messages from webpage to extension +window.addEventListener('kapture-message', (event) => { + chrome.runtime.sendMessage(event.detail); +}); + +// Listen for console log events from the injected code +window.addEventListener('kapture-console', (event) => { + // Send console log to background script + chrome.runtime.sendMessage({ + type: 'consoleLog', + ...event.detail + }); +}); +function ready() { + // Notify background script that content script is ready + chrome.runtime.sendMessage({ type: 'contentScriptReady' }); + + document.body.classList.add('kapture-loaded'); + window.dispatchEvent(new CustomEvent('kapture-loaded')); + + // Check for auto-connect querystring parameter + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('kapture-connect') === 'true') { + chrome.runtime.sendMessage({ type: 'connect' }); + chrome.runtime.sendMessage({ type: 'openPopup' }); + } +} + +// Notify webpage that Kapture is loaded after DOMContentLoaded +document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', ready) : ready(); diff --git a/extension-kapture/devtools.html b/extension-kapture/devtools.html new file mode 100644 index 0000000..5ca8c2f --- /dev/null +++ b/extension-kapture/devtools.html @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/extension-kapture/devtools.js b/extension-kapture/devtools.js new file mode 100644 index 0000000..153e6a1 --- /dev/null +++ b/extension-kapture/devtools.js @@ -0,0 +1,6 @@ +// Create DevTools panel +chrome.devtools.panels.create( + "Kapture", + null, + "panel.html" +); \ No newline at end of file diff --git a/extension-kapture/icons/icon128.png b/extension-kapture/icons/icon128.png new file mode 100644 index 0000000..edcbaa6 Binary files /dev/null and b/extension-kapture/icons/icon128.png differ diff --git a/extension-kapture/icons/icon16.png b/extension-kapture/icons/icon16.png new file mode 100644 index 0000000..46cd181 Binary files /dev/null and b/extension-kapture/icons/icon16.png differ diff --git a/extension-kapture/icons/icon48.png b/extension-kapture/icons/icon48.png new file mode 100644 index 0000000..cf58765 Binary files /dev/null and b/extension-kapture/icons/icon48.png differ diff --git a/extension-kapture/manifest.json b/extension-kapture/manifest.json new file mode 100644 index 0000000..f3107d1 --- /dev/null +++ b/extension-kapture/manifest.json @@ -0,0 +1,44 @@ +{ + "manifest_version": 3, + "name": "Kapture MCP Browser Automation", + "short_name": "Kapture", + "version": "0.14.0", + "description": "Remote browser automation via MCP - DevTools Extension", + "author": "William Kapke", + "homepage_url": "https://github.com/williamkapke/kapture", + "permissions": ["activeTab", "debugger"], + "host_permissions": [""], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "devtools_page": "devtools.html", + "content_scripts": [ + { + "matches": [""], + "js": ["console-listener.js"], + "run_at": "document_start", + "all_frames": false, + "world": "MAIN" + }, + { + "matches": [""], + "js": ["page-helpers.js", "content-script.js"], + "run_at": "document_start", + "all_frames": false + } + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/extension-kapture/modules/background-click.js b/extension-kapture/modules/background-click.js new file mode 100644 index 0000000..b583c6b --- /dev/null +++ b/extension-kapture/modules/background-click.js @@ -0,0 +1,122 @@ +// Import helper functions from background-commands +import { getFromContentScript, respondWith, respondWithError, attachDebugger, getElement } from './background-commands.js'; + +export async function click({tabId, mousePosition}, { selector, xpath }) { + return await hover({ tabId, mousePosition }, { selector, xpath }, true); +} + +export async function hover(tab, { selector, xpath }, click = false) { + const { tabId, mousePosition } = tab; + // Validate that either selector or xpath is provided + if (!selector && !xpath) { + return respondWithError(tabId, 'SELECTOR_OR_XPATH_REQUIRED', 'Either selector or xpath is required'); + } + + // Get element and validate it exists and is visible + const elementResult = await getElement(tabId, selector, xpath, true); + if (elementResult.error) return elementResult; + + // Get current mouse position + const currentPosition = mousePosition || { x: 0, y: 0 }; + + // Calculate target position (center of element) + const targetX = elementResult.element.bounds.x + elementResult.element.bounds.width / 2; + const targetY = elementResult.element.bounds.y + elementResult.element.bounds.height / 2; + + try { + // Show cursor + await getFromContentScript(tabId, '_cursor', { show: true }); + + // Set initial cursor position + await getFromContentScript(tabId, '_moveMouseSVG', { x: currentPosition.x, y: currentPosition.y }); + + // Animation configuration + const pixelsPerSecond = 1000; // Adjust for desired speed + const frameInterval = 16; // ~60fps + + await attachDebugger(tabId, async () => { + const sendCmd = (cmd, params) => chrome.debugger.sendCommand({ tabId }, cmd, params); + const dispatchMouseEvent = (params) => sendCmd('Input.dispatchMouseEvent', params); + + // Enable Input domain for mouse events + // await sendCmd('Input.enable'); + + // Function to animate to a position + const animateToPosition = async (fromX, fromY, toX, toY) => { + const distance = Math.sqrt(Math.pow(toX - fromX, 2) + Math.pow(toY - fromY, 2)); + const duration = Math.min(1000, (distance / pixelsPerSecond) * 1000); + const animSteps = Math.max(1, Math.ceil(duration / frameInterval)); + const dx = (toX - fromX) / animSteps; + const dy = (toY - fromY) / animSteps; + + for (let i = 1; i <= animSteps; i++) { + const x = fromX + (dx * i); + const y = fromY + (dy * i); + + // Move visual cursor + await getFromContentScript(tabId, '_moveMouseSVG', { x, y }); + + // Send mouse move event + await dispatchMouseEvent({ type: 'mouseMoved', x, y }); + + // Wait for next frame + await new Promise(resolve => setTimeout(resolve, frameInterval)); + } + + return { x: toX, y: toY }; + }; + + // Initial animation to target + let finalPosition = await animateToPosition(currentPosition.x, currentPosition.y, targetX, targetY); + + // Try up to 5 times to ensure we're over the target element + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const finalCheck = await getElement(tabId, selector, xpath, true); + if (!finalCheck.error && finalCheck.element) { + const bounds = finalCheck.element.bounds; + + // Check if cursor is actually over the element + const isOverElement = finalPosition.x >= bounds.x && + finalPosition.x <= bounds.x + bounds.width && + finalPosition.y >= bounds.y && + finalPosition.y <= bounds.y + bounds.height; + + if (!isOverElement) { + // Calculate new target center and animate to it + const actualTargetX = bounds.x + bounds.width / 2; + const actualTargetY = bounds.y + bounds.height / 2; + finalPosition = await animateToPosition(finalPosition.x, finalPosition.y, actualTargetX, actualTargetY); + } else { + // Cursor is over the element, we're done + break; + } + } else { + // Element not found, stop trying + break; + } + } + + const bounds = await getFromContentScript(tabId, '_elementPosition', { id: elementResult.element.id }); + const actualTargetX = bounds.x + bounds.width / 2; + const actualTargetY = bounds.y + bounds.height / 2; + await getFromContentScript(tabId, '_moveMouseSVG', { x: actualTargetX, y: actualTargetY }); + + if (click) { + await dispatchMouseEvent({type: 'mousePressed', x: actualTargetX, y: actualTargetY, button: 'left', clickCount: 1}); + await dispatchMouseEvent({type: 'mouseReleased', x: actualTargetX, y: actualTargetY, button: 'left', clickCount: 1}); + } + }); + + // Hide cursor after 1 second + setTimeout(async () => { + await getFromContentScript(tabId, '_cursor', { show: false }); + }, 1000); + + return respondWith(tabId, click ? { clicked: true } : { hovered: true }, selector, xpath); + } catch (error) { + // Make sure cursor is hidden on error + await getFromContentScript(tabId, '_cursor', { show: false }); + return respondWithError(tabId, 'CLICK_FAILED', error.message, selector, xpath); + } +} diff --git a/extension-kapture/modules/background-commands.js b/extension-kapture/modules/background-commands.js new file mode 100644 index 0000000..8739f8b --- /dev/null +++ b/extension-kapture/modules/background-commands.js @@ -0,0 +1,88 @@ +import { keypress } from './background-keypress.js'; +import { click, hover } from './background-click.js'; +import { navigate, back, forward, close, reload, show } from './background-navigate.js'; +import { screenshot } from './background-screenshot.js'; +import { getLogs } from './background-console.js'; + +export const getFromContentScript = async (tabId, command, params, ) => { + return await chrome.tabs.sendMessage(tabId, { command, params }); +} + +// Detect browser type from user agent +export const detectBrowser = () => { + const userAgent = navigator.userAgent.toLowerCase(); + + // Check for Chromium-based browsers that support Chrome extensions + if (userAgent.includes('edg/')) { + return 'edge'; + } else if (userAgent.includes('opr/') || userAgent.includes('opera/')) { + return 'opera'; + } else if (userAgent.includes('vivaldi/')) { + return 'vivaldi'; + } else if (userAgent.includes('brave/')) { + return 'brave'; + } else if (userAgent.includes('chrome/')) { + // Additional check for Brave which doesn't always include 'brave' in UA + if (navigator.brave && navigator.brave.isBrave) { + return 'brave'; + } + return 'chrome'; + } else { + // Default to chromium for any other Chromium-based browser + return 'chromium'; + } +}; + +export const getTabInfo = async(tabId) => await getFromContentScript(tabId, 'getTabInfo'); +export const getElement = async (tabId, selector, xpath, visible) => { + return await getFromContentScript(tabId, 'element', { selector, xpath, visible }); +} + +export const respondWith = async (tabId, obj, selector, xpath) => { + const info = await getTabInfo(tabId); + return { + success: !obj.error, + selector, + xpath: !selector ? xpath : undefined, + ...info, + ...obj + }; +} +export const respondWithError = async (tabId, code, message, selector, xpath) => { + return respondWith(tabId,{ error: { code, message } }, selector, xpath); +} +export async function attachDebugger(tabId, action) { + let debuggerAttached = false; + try { + // Attach debugger to capture screenshot without making tab active + await chrome.debugger.attach({tabId}, '1.3'); + debuggerAttached = true; + + // Enable the Page domain + await chrome.debugger.sendCommand({tabId}, 'Page.enable'); + + return await action(); + } + finally { + // Always detach debugger if attached + try { + if (debuggerAttached) await chrome.debugger.detach({tabId: tabId}); + } + catch (e) { } + } +} + + +export const backgroundCommands = { + navigate, + back, + forward, + close, + reload, + show, + click, + hover, + keypress, + screenshot, + getLogs +} diff --git a/extension-kapture/modules/background-console.js b/extension-kapture/modules/background-console.js new file mode 100644 index 0000000..3afffb4 --- /dev/null +++ b/extension-kapture/modules/background-console.js @@ -0,0 +1,21 @@ +// Import helper functions from background-commands +import { respondWith, respondWithError } from './background-commands.js'; + +export async function getLogs(tabState, { before, limit = 100, level }) { + try { + // Get the logs from the tab state using its built-in method + const logs = tabState.getConsoleLogs(limit, level, before); + + // Check if there are more logs available + const totalFilteredCount = tabState.getConsoleLogs(null, level, before).length; + const hasMore = totalFilteredCount > limit; + + return respondWith(tabState.tabId, { + logs: logs, + hasMore: hasMore, + totalCount: tabState.getConsoleLogCount() + }); + } catch (error) { + return respondWithError(tabState.tabId, 'CONSOLE_LOG_ERROR', error.message); + } +} diff --git a/extension-kapture/modules/background-keypress.js b/extension-kapture/modules/background-keypress.js new file mode 100644 index 0000000..9540d6a --- /dev/null +++ b/extension-kapture/modules/background-keypress.js @@ -0,0 +1,214 @@ +// Import helper functions from background-commands +import { getFromContentScript, respondWith, respondWithError, attachDebugger } from './background-commands.js'; + +// Additional helper functions +const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// Send keypress event +export async function keypress({tabId}, params) { + const { key, selector, xpath, delay = 50 } = params; + + if (!key) { + return respondWithError(tabId, 'KEY_REQUIRED', 'Key parameter is required'); + } + + // Validate delay is within reasonable bounds (0-60000ms = 0-60 seconds) + const keypressDelay = Math.max(0, Math.min(60000, delay)); + + try { + // First, focus the target element if selector/xpath provided + if (selector || xpath) { + const focusResult = await getFromContentScript(tabId, 'focus', { selector, xpath }); + if (focusResult.error) { + return focusResult; + } + } + + // Parse key combination to extract key and modifiers + const keyData = parseKeyCombination(key); + + // Use attachDebugger helper to manage debugger lifecycle + return await attachDebugger(tabId, async () => { + // Helper function to send key event + const sendKeyEvent = async (type, autoRepeat = false, text = null) => { + const eventData = { + type, + ...keyData, + windowsVirtualKeyCode: keyData.keyCode, + nativeVirtualKeyCode: keyData.keyCode + }; + + if (type === 'keyDown' || type === 'keyUp') { + eventData.autoRepeat = autoRepeat; + } + + if (text) { + eventData.text = text; + } + + await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', eventData); + }; + + // Send keydown event + await sendKeyEvent('keyDown', false); + + // If delay > 500ms, simulate key repeat + if (keypressDelay > 500) { + // Calculate number of repeat events based on delay + // Typical repeat rate is ~30ms between events + const repeatInterval = 30; + const repeatCount = Math.floor((keypressDelay - 100) / repeatInterval); + + // Wait a bit before starting repeat (typical OS behavior) + await wait(50); + + // Send repeated keydown events + for (let i = 0; i < repeatCount; i++) { + await sendKeyEvent('keyDown', true); + + // Wait between repeat events + if (i < repeatCount - 1) { + await wait(repeatInterval); + } + } + } else if (keypressDelay > 0) { + // For delays <= 500ms, just wait the specified time + await wait(keypressDelay); + } + + // Send keyup event + await sendKeyEvent('keyUp'); + + // Wait a bit for the page to process the key events + await wait(50); + + // Keys that typically cause scrolling need extra time to settle + const scrollKeys = ['PageUp', 'PageDown', 'Space', ' ', 'ArrowUp', 'ArrowDown', 'Home', 'End']; + if (!selector && !xpath && scrollKeys.includes(keyData.key)) { + // Additional delay for scroll to complete when key is pressed globally (not on a specific element) + await wait(100); + } + + // Get result including element info if selector was provided + const resultData = { + keyPressed: true, + key: key, + delay: keypressDelay + }; + + // If this was an auto-repeat key press, include that info + if (keypressDelay > 500) { + resultData.autoRepeat = true; + resultData.repeatCount = Math.floor((keypressDelay - 100) / 30); + } + + return await respondWith(tabId, resultData, selector, xpath); + }); + } catch (error) { + return respondWithError(tabId, 'KEYPRESS_FAILED', error.message, selector, xpath); + } +} + +// Parse key combination and return CDP-compatible key data +function parseKeyCombination(keyCombination) { + let modifiers = 0; + let key = ''; + + // CDP modifier flags + const CDP_MODIFIERS = { + alt: 1, + ctrl: 2, + meta: 4, + shift: 8 + }; + + // Special key mappings + const KEY_MAPPINGS = { + 'enter': { key: 'Enter', code: 'Enter', keyCode: 13 }, + 'return': { key: 'Enter', code: 'Enter', keyCode: 13 }, + 'tab': { key: 'Tab', code: 'Tab', keyCode: 9 }, + 'delete': { key: 'Delete', code: 'Delete', keyCode: 46 }, + 'backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 }, + 'escape': { key: 'Escape', code: 'Escape', keyCode: 27 }, + 'esc': { key: 'Escape', code: 'Escape', keyCode: 27 }, + 'space': { key: ' ', code: 'Space', keyCode: 32, text: ' ' }, + ' ': { key: ' ', code: 'Space', keyCode: 32, text: ' ' }, + 'arrowup': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 }, + 'arrowdown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }, + 'arrowleft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 }, + 'arrowright': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 }, + 'pageup': { key: 'PageUp', code: 'PageUp', keyCode: 33 }, + 'pagedown': { key: 'PageDown', code: 'PageDown', keyCode: 34 }, + 'home': { key: 'Home', code: 'Home', keyCode: 36 }, + 'end': { key: 'End', code: 'End', keyCode: 35 }, + 'insert': { key: 'Insert', code: 'Insert', keyCode: 45 }, + 'f1': { key: 'F1', code: 'F1', keyCode: 112 }, + 'f2': { key: 'F2', code: 'F2', keyCode: 113 }, + 'f3': { key: 'F3', code: 'F3', keyCode: 114 }, + 'f4': { key: 'F4', code: 'F4', keyCode: 115 }, + 'f5': { key: 'F5', code: 'F5', keyCode: 116 }, + 'f6': { key: 'F6', code: 'F6', keyCode: 117 }, + 'f7': { key: 'F7', code: 'F7', keyCode: 118 }, + 'f8': { key: 'F8', code: 'F8', keyCode: 119 }, + 'f9': { key: 'F9', code: 'F9', keyCode: 120 }, + 'f10': { key: 'F10', code: 'F10', keyCode: 121 }, + 'f11': { key: 'F11', code: 'F11', keyCode: 122 }, + 'f12': { key: 'F12', code: 'F12', keyCode: 123 } + }; + + // Parse modifiers from combination + let remaining = keyCombination; + const modifierPatterns = [ + { pattern: /^(Control|Ctrl)\+/i, modifier: 'ctrl' }, + { pattern: /^Shift\+/i, modifier: 'shift' }, + { pattern: /^Alt|Option\+/i, modifier: 'alt' }, + { pattern: /^(Meta|Cmd|Command)\+/i, modifier: 'meta' } + ]; + + // Extract modifiers + let foundModifier = true; + while (foundModifier && remaining) { + foundModifier = false; + for (const { pattern, modifier } of modifierPatterns) { + if (pattern.test(remaining)) { + modifiers |= CDP_MODIFIERS[modifier]; + remaining = remaining.replace(pattern, ''); + foundModifier = true; + break; + } + } + } + + // What's left is the key + key = remaining; + + // Look up special key mapping + const lowerKey = key.toLowerCase(); + if (KEY_MAPPINGS[lowerKey]) { + return { + ...KEY_MAPPINGS[lowerKey], + modifiers, + text: KEY_MAPPINGS[lowerKey].text || undefined + }; + } + + // For single character keys + if (key.length === 1) { + const keyCode = key.toUpperCase().charCodeAt(0); + return { + key: key, + code: 'Key' + key.toUpperCase(), + keyCode: keyCode, + modifiers, + text: modifiers === 0 ? key : undefined // Only send text for unmodified keys + }; + } + + // For other keys, try to generate a reasonable code + return { + key: key, + code: key, + keyCode: 0, // Unknown keycode + modifiers + }; +} diff --git a/extension-kapture/modules/background-navigate.js b/extension-kapture/modules/background-navigate.js new file mode 100644 index 0000000..c24256e --- /dev/null +++ b/extension-kapture/modules/background-navigate.js @@ -0,0 +1,82 @@ +// Import helper functions from background-commands +import { getFromContentScript, respondWith, respondWithError } from './background-commands.js'; + +// Check if URL is allowed for extension +const isAllowedUrl = (url) => !!url && (url.startsWith('http://') || url.startsWith('https://')); + +// Wait for content script to be ready +async function waitForContentScriptReady(tabId, timeout = 5000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.runtime.onMessage.removeListener(listener); + reject(new Error('Timeout waiting for content script')); + }, timeout); + + const listener = (request, sender) => { + if (request.type === 'contentScriptReady' && sender.tab?.id === tabId) { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(); + } + }; + + chrome.runtime.onMessage.addListener(listener); + }); +} + +// Execute navigation and wait for content script +async function executeNavigation(tabId, navigationFn) { + try { + await navigationFn(); + await waitForContentScriptReady(tabId); + return await respondWith(tabId, {}); + } catch (error) { + return respondWithError(tabId, 'NAVIGATION_FAILED', error.message); + } +} + +// Navigation commands +export async function navigate({tabId}, { url }) { + if (!isAllowedUrl(url)) { + return respondWithError(tabId, 'NAVIGATION_BLOCKED', `Navigation to ${url} is not allowed`); + } + return executeNavigation(tabId, async () => getFromContentScript(tabId, '_navigate', { url })); +} + +export async function back({tabId}) { + return executeNavigation(tabId, () => chrome.tabs.goBack(tabId)); +} + +export async function forward({tabId}) { + return executeNavigation(tabId, () => chrome.tabs.goForward(tabId)); +} + +export async function close({tabId}) { + try { + await chrome.tabs.remove(tabId); + return { success: true, closed: true }; + } catch (error) { + return { success: false, error: { code: 'CLOSE_FAILED', message: error.message } }; + } +} + +export async function reload({tabId}) { + return executeNavigation(tabId, () => chrome.tabs.reload(tabId)); +} + +export async function show({tabId}) { + try { + // Get the tab to find its window ID + const tab = await chrome.tabs.get(tabId); + + // Bring the window to the front + await chrome.windows.update(tab.windowId, { focused: true }); + + // Make the tab active in the window + await chrome.tabs.update(tabId, { active: true }); + + return await respondWith(tabId, { shown: true }); + } catch (error) { + return respondWithError(tabId, 'SHOW_FAILED', error.message); + } +} \ No newline at end of file diff --git a/extension-kapture/modules/background-screenshot.js b/extension-kapture/modules/background-screenshot.js new file mode 100644 index 0000000..0f543fa --- /dev/null +++ b/extension-kapture/modules/background-screenshot.js @@ -0,0 +1,54 @@ +// Import helper functions from background-commands +import { getElement, getTabInfo, respondWithError, attachDebugger } from './background-commands.js'; + +export async function screenshot({tabId}, { scale = 0.5, quality = 0.5, format = 'webp', selector, xpath }) { + let elementResult; + if (selector || xpath) { + elementResult = await getElement(tabId, selector, xpath, true); + if (elementResult.error) return elementResult; + } + else { + elementResult = await getTabInfo(tabId) + elementResult.element = { + bounds: { + x: elementResult.scrollPosition.x, + y: elementResult.scrollPosition.y, + width: elementResult.viewportDimensions.width, + height: elementResult.viewportDimensions.height + } + }; + } + + const clip = { ...elementResult.element.bounds }; + + // For fixed positioned elements, we need viewport-relative coordinates + // For non-fixed elements, we need document-relative coordinates + if (elementResult.element.position !== 'fixed') { + // Add scroll position to convert from viewport to document coordinates + clip.x += elementResult.scrollPosition.x; + clip.y += elementResult.scrollPosition.y; + } + + if (scale) { + clip.scale = scale; + } + + return attachDebugger(tabId, async () => { + const screenshot = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', { + format, + quality: Math.round(quality * 100), // Chrome needs an integer percentage, + clip + }); + + return { + ...elementResult, + element: undefined, + selector: elementResult.element?.selector || undefined, + mimeType: `image/${format}`, + data: screenshot.data, + }; + }) + .catch((err) => { + return respondWithError(tabId,'SCREENSHOT_ERROR', err.message, null, null); + }); +} \ No newline at end of file diff --git a/extension-kapture/modules/models.js b/extension-kapture/modules/models.js new file mode 100644 index 0000000..60f14a5 --- /dev/null +++ b/extension-kapture/modules/models.js @@ -0,0 +1,33 @@ + +// Connection state enum +export const ConnectionStatus = { + DISCONNECTED: 'disconnected', + CONNECTING: 'connecting', + CONNECTED: 'connected', + RETRYING: 'retrying', + ERROR: 'error' +}; + +// Message class +export class Message { + constructor(direction, data) { + this.id = crypto.randomUUID(); + this.direction = direction; // 'incoming' | 'outgoing' + this.data = data; + this.timestamp = new Date(); + } +} + +// Console log entry class +export class ConsoleLogEntry { + constructor(level, args, stackTrace = null) { + this.id = crypto.randomUUID(); + this.level = level; // 'log' | 'info' | 'warn' | 'error' + this.args = args; + if (stackTrace) { + this.stackTrace = stackTrace; + } + this.timestamp = new Date(); + } +} + diff --git a/extension-kapture/modules/tab-manager.js b/extension-kapture/modules/tab-manager.js new file mode 100644 index 0000000..61f9c58 --- /dev/null +++ b/extension-kapture/modules/tab-manager.js @@ -0,0 +1,342 @@ +import { TabState } from './tab-state.js'; +import { backgroundCommands, getTabInfo, detectBrowser } from './background-commands.js'; + +export class TabManager { + constructor() { + this.tabs = new Map(); // tabId -> TabState + this.listeners = new Set(); // State change listeners + } + + // Tab lifecycle + getOrCreateTab(tabId) { + if (!this.tabs.has(tabId)) { + const tabState = new TabState(tabId); + this.tabs.set(tabId, tabState); + this.notifyListeners(tabId, 'created', tabState); + } + return this.tabs.get(tabId); + } + + getTab(tabId) { + return this.tabs.get(tabId); + } + + hasTab(tabId) { + return this.tabs.has(tabId); + } + + removeTab(tabId) { + const tabState = this.tabs.get(tabId); + if (tabState) { + tabState.cleanup(); + this.tabs.delete(tabId); + this.notifyListeners(tabId, 'removed', null); + } + } + + // WebSocket connection management + async connect(tabId) { + const tabState = this.getOrCreateTab(tabId); + + if (tabState.websocket && tabState.websocket.readyState === WebSocket.OPEN) { + return { ok: true, message: 'Already connected' }; + } + + // Get tab info from content script + const tabInfo = await getTabInfo(tabId); + tabState.updatePageMetadata(tabInfo); + + // Set up connection + tabState.connectionInfo.userDisconnected = false; + + await this._createConnection(tabState); + + return { ok: true }; + } + + disconnect(tabId) { + const tabState = this.getTab(tabId); + if (!tabState) { + return { ok: false, error: 'Tab not found' }; + } + + tabState.connectionInfo.setDisconnected(true); + + // Clear keepalive interval + if (tabState.keepaliveInterval) { + clearInterval(tabState.keepaliveInterval); + tabState.keepaliveInterval = null; + } + + if (tabState.websocket) { + tabState.websocket.close(); + tabState.clearWebSocket(); + } + + // Clear any pending reconnect + if (tabState.connectionInfo.reconnectTimer) { + clearTimeout(tabState.connectionInfo.reconnectTimer); + tabState.connectionInfo.reconnectTimer = null; + } + + this.notifyListeners(tabId, 'stateChanged', tabState); + + return { ok: true }; + } + + // Private connection methods + async _createConnection(tabState) { + // First check if tab still exists + try { + await chrome.tabs.get(tabState.tabId); + } catch (error) { + console.log(`Tab ${tabState.tabId} no longer exists, aborting connection`); + this.removeTab(tabState.tabId); + return; + } + + const ws = new WebSocket(tabState.connectionInfo.url); + tabState.setWebSocket(ws); + + // Clear any existing keepalive interval + if (tabState.keepaliveInterval) { + clearInterval(tabState.keepaliveInterval); + tabState.keepaliveInterval = null; + } + + ws.onopen = () => { + tabState.connectionInfo.setConnected(); + this.notifyListeners(tabState.tabId, 'stateChanged', tabState); + + // Send registration message with Chrome tab ID and metadata + const registerMessage = { + type: 'register', + requestedTabId: tabState.tabId.toString(), // Chrome tab ID + browser: detectBrowser(), // Add browser type + ...tabState.pageMetadata + }; + + this.sendMessage(tabState.tabId, registerMessage); + + // Set up keepalive ping every 30 seconds + tabState.keepaliveInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } catch (e) { + console.error('Failed to send keepalive ping:', e); + } + } + }, 30000); + }; + + ws.onclose = () => { + // Clear keepalive interval + if (tabState.keepaliveInterval) { + clearInterval(tabState.keepaliveInterval); + tabState.keepaliveInterval = null; + } + + tabState.clearWebSocket(); + + if (!tabState.connectionInfo.userDisconnected) { + // Schedule reconnect + this._scheduleReconnect(tabState); + } else { + tabState.connectionInfo.setDisconnected(true); + } + + this.notifyListeners(tabState.tabId, 'stateChanged', tabState); + }; + + ws.onerror = (error) => { + console.error(`WebSocket error for tab ${tabState.tabId}:`, error); + tabState.connectionInfo.setError(error); + // Don't clear WebSocket here - let onclose handle cleanup and reconnection + this.notifyListeners(tabState.tabId, 'stateChanged', tabState); + }; + + ws.onmessage = async (event) => { + try { + const data = JSON.parse(event.data); + + // Don't track pong messages + if (data.type !== 'pong') { + const message = tabState.addMessage('incoming', data); + this.notifyListeners(tabState.tabId, 'messageReceived', tabState, message); + } + + // Handle commands + if (data.type === 'command' && data.command && data.id) { + await this._handleCommand(tabState, data); + } else if (data.type === 'pong') { + // Pong received - connection is healthy + tabState.lastPongTime = Date.now(); + } + } catch (e) { + console.error('Failed to parse WebSocket message:', e); + } + }; + } + + _scheduleReconnect(tabState) { + // Clear any existing reconnect timer + if (tabState.connectionInfo.reconnectTimer) { + clearTimeout(tabState.connectionInfo.reconnectTimer); + tabState.connectionInfo.reconnectTimer = null; + } + + const attemptNumber = tabState.connectionInfo.reconnectAttempts; + const backoffMs = this._getBackoffMs(attemptNumber); + + console.log(`Scheduling reconnect for tab ${tabState.tabId} in ${backoffMs}ms (attempt ${attemptNumber + 1})`); + + tabState.connectionInfo.setRetrying(attemptNumber + 1, backoffMs); + this.notifyListeners(tabState.tabId, 'stateChanged', tabState); + + tabState.connectionInfo.reconnectTimer = setTimeout(async () => { + if (!tabState.connectionInfo.userDisconnected) { + // Check if tab still exists before attempting reconnect + try { + await chrome.tabs.get(tabState.tabId); + console.log(`Attempting reconnect for tab ${tabState.tabId}`); + this._createConnection(tabState); + } catch (error) { + console.log(`Tab ${tabState.tabId} no longer exists, removing from manager`); + this.removeTab(tabState.tabId); + } + } + }, backoffMs); + } + + _getBackoffMs(attemptNumber) { + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, then cap at 60s + return Math.min(1000 * Math.pow(2, attemptNumber), 60000); + } + + async _handleCommand(tabState, {command, params, id}) { + try { + let result; + // some need to run with the background context + if (backgroundCommands[command]) { + result = await backgroundCommands[command](tabState, params); + } + // others we execute in the page context + else { + result = await chrome.tabs.sendMessage(tabState.tabId, {command, params}); + } + // `success: true` means we didn't throw an error. TODO: rename or remove it + const response = {id, type: 'response', success: true, result}; + this.sendMessage(tabState.tabId, response); + } + catch (error) { + const errorResponse = { id, type: 'response', success: false, error: { message: error.message, code: 'COMMAND_FAILED' }}; + this.sendMessage(tabState.tabId, errorResponse); + } + } + + // Message sending + sendMessage(tabId, data) { + const tabState = this.getTab(tabId); + if (!tabState || !tabState.websocket || tabState.websocket.readyState !== WebSocket.OPEN) { + return { ok: false, error: 'Not connected' }; + } + + try { + const message = tabState.addMessage('outgoing', data); + tabState.websocket.send(JSON.stringify(data)); + this.notifyListeners(tabId, 'messageSent', tabState, message); + return { ok: true }; + } catch (e) { + return { ok: false, error: e.message }; + } + } + + // Message management + clearMessages(tabId) { + const tabState = this.getTab(tabId); + if (tabState) { + tabState.clearMessages(); + this.notifyListeners(tabId, 'messagesCleared', tabState); + } + } + + // Console log management + addConsoleLog(tabId, log) { + const tabState = this.getTab(tabId); + if (tabState) { + tabState.addConsoleLog(log); + this.notifyListeners(tabId, 'consoleLogAdded', tabState, log); + } + } + + clearConsoleLogs(tabId) { + const tabState = this.getTab(tabId); + if (tabState) { + tabState.clearConsoleLogs(); + this.notifyListeners(tabId, 'consoleLogsCleared', tabState); + } + } + + // Port management + addPort(tabId, port) { + const tabState = this.getOrCreateTab(tabId); + tabState.addPort(port); + + // Send initial state + port.postMessage({ + type: 'state', + tabId, + ...tabState.getConnectionState() + }); + + // Send current messages + port.postMessage({ + type: 'messages', + tabId, + messages: tabState.getMessages() + }); + + // Send console count + port.postMessage({ + type: 'consoleCount', + tabId, + count: tabState.getConsoleLogCount() + }); + } + + removePort(tabId, port) { + const tabState = this.getTab(tabId); + if (tabState) { + tabState.removePort(port); + } + } + + // Event listeners + addListener(callback) { + this.listeners.add(callback); + } + + removeListener(callback) { + this.listeners.delete(callback); + } + + notifyListeners(tabId, event, tabState, data = null) { + this.listeners.forEach(callback => { + try { + callback(tabId, event, tabState, data); + } catch (e) { + console.error('Error in TabManager listener:', e); + } + }); + } + + // Utility + getAllTabs() { + return Array.from(this.tabs.values()); + } + + getActiveTabCount() { + return this.tabs.size; + } +} diff --git a/extension-kapture/modules/tab-state.js b/extension-kapture/modules/tab-state.js new file mode 100644 index 0000000..5ba7a17 --- /dev/null +++ b/extension-kapture/modules/tab-state.js @@ -0,0 +1,177 @@ + +// WebSocket connection info +import { ConnectionStatus, Message } from "./models.js"; + +export class ConnectionInfo { + constructor(url) { + this.url = url; + this.status = ConnectionStatus.DISCONNECTED; + this.connected = false; + this.userDisconnected = false; + this.reconnectAttempts = 0; + this.reconnectTimer = null; + this.nextRetryIn = null; + } + + setConnected() { + this.status = ConnectionStatus.CONNECTED; + this.connected = true; + this.reconnectAttempts = 0; + this.nextRetryIn = null; + } + + setDisconnected(userInitiated = false) { + this.status = ConnectionStatus.DISCONNECTED; + this.connected = false; + this.userDisconnected = userInitiated; + if (userInitiated) { + this.reconnectAttempts = 0; + this.nextRetryIn = null; + } + } + + setRetrying(attemptNumber, nextRetryMs) { + this.status = ConnectionStatus.RETRYING; + this.connected = false; + this.reconnectAttempts = attemptNumber; + this.nextRetryIn = nextRetryMs; + } + + setError(error) { + this.status = ConnectionStatus.ERROR; + this.connected = false; + } +} + +// Main tab state class +export class TabState { + constructor(tabId) { + this.tabId = tabId; + this.websocket = null; + this.connectionInfo = new ConnectionInfo('ws://localhost:61822'); + this.messages = []; + this.consoleLogs = []; + this.ports = new Set(); // Connected DevTools panels/popups + this.pageMetadata = {}; + this.mousePosition = { x: 0, y: 0 }; // Track current mouse position + } + + // WebSocket management + setWebSocket(ws) { + this.websocket = ws; + } + + clearWebSocket() { + this.websocket = null; + } + + // Message management + addMessage(direction, data) { + const message = new Message(direction, data); + this.messages.push(message); + return message; + } + + clearMessages() { + this.messages = []; + } + + getMessages(limit = null) { + if (limit === null) { + return [...this.messages]; + } + return this.messages.slice(-limit); + } + + // Console log management + addConsoleLog(log) { + this.consoleLogs.unshift(log); + } + + clearConsoleLogs() { + this.consoleLogs = []; + } + + getConsoleLogs(limit = null, level = null, before = null) { + let logs = this.consoleLogs; + + if (level) { + logs = logs.filter(log => log.level === level); + } + + if (before) { + const beforeTimestamp = new Date(before).getTime(); + logs = logs.filter(log => + new Date(log.timestamp).getTime() < beforeTimestamp + ); + } + + if (limit === null) { + return [...logs]; + } + return logs.slice(-limit); + } + + getConsoleLogCount() { + return this.consoleLogs.length; + } + + // Port management + addPort(port) { + this.ports.add(port); + } + + removePort(port) { + this.ports.delete(port); + } + + broadcastToPorts(message) { + this.ports.forEach(port => { + try { + port.postMessage(message); + } catch (e) { + // Port might be disconnected + this.ports.delete(port); + } + }); + } + + // State management + getConnectionState() { + return { + connected: this.connectionInfo.connected, + status: this.connectionInfo.status, + reconnectAttempt: this.connectionInfo.reconnectAttempts, + nextRetryIn: this.connectionInfo.nextRetryIn + }; + } + + updatePageMetadata(metadata) { + this.pageMetadata = metadata; + } + + // Mouse position tracking + setMousePosition(position) { + this.mousePosition = { ...position }; + } + + // Cleanup + cleanup() { + // Clear reconnect timer + if (this.connectionInfo.reconnectTimer) { + clearTimeout(this.connectionInfo.reconnectTimer); + this.connectionInfo.reconnectTimer = null; + } + + // Close WebSocket + if (this.websocket) { + this.websocket.close(); + this.websocket = null; + } + + // Clear all data + this.messages = []; + this.consoleLogs = []; + this.ports.clear(); + } +} diff --git a/extension-kapture/page-helpers.js b/extension-kapture/page-helpers.js new file mode 100644 index 0000000..3a5ffc7 --- /dev/null +++ b/extension-kapture/page-helpers.js @@ -0,0 +1,683 @@ +// page-helpers.js - Content script that provides helper functions +let kaptureIdCounter = 0; + +function getUniqueSelector(element) { + if (!element || !(element instanceof Element)) return null; + + // Special handling for html, head, and body elements - their tagName is unique + const tagName = element.tagName.toLowerCase(); + if (tagName === 'html' || tagName === 'head' || tagName === 'body') { + return tagName; + } + + // If element has an ID, use it (unless it's empty or contains special chars) + if (element.id && /^[a-zA-Z][\w-]*$/.test(element.id)) { + // Check if ID is truly unique + if (document.querySelectorAll('#' + CSS.escape(element.id)).length === 1) { + return '#' + CSS.escape(element.id); + } + } + + const uniqueId = 'kapture-' + (++kaptureIdCounter) + + if (!element.id) { + element.id = uniqueId; + return '#' + uniqueId; + } + + element.classList.add(uniqueId); + return '.' + uniqueId +} +function findScrollableParent(element) { + function isScrollable(element) { + const hasScrollableContent = element.scrollHeight > element.clientHeight || + element.scrollWidth > element.clientWidth; + + if (!hasScrollableContent) return false; + + const style = getComputedStyle(element); + return /(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX); + } + + let parent = element.parentElement; + while (parent && parent !== document.body) { + if (isScrollable(parent)) return parent; + parent = parent.parentElement; + } + return document.documentElement; +} +function serializeValue(value, depth = 0, maxDepth = 3, seen = new WeakSet()) { + // Handle primitive types + if (value === null || value === undefined) return value; + if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { + return value; + } + + if (typeof value === 'function') return '[Function: ' + (value.name || 'anonymous') + ']'; + if (typeof value === 'symbol') return value.toString(); + if (value instanceof Date) return value.toISOString(); + if (value instanceof RegExp) return value.toString(); + + // Handle errors + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack + }; + } + + // Handle DOM elements + if (value instanceof Element) { + const selector = getUniqueSelector(value); + return { + nodeType: 'ELEMENT_NODE', + selector: selector, + tagName: value.tagName, + id: value.id || undefined, + className: value.className || undefined, + attributes: Array.from(value.attributes).reduce((acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, {}) + }; + } + + // Prevent infinite recursion + if (depth >= maxDepth) { + return '[Max depth reached]'; + } + + // Handle circular references + if (typeof value === 'object' && seen.has(value)) { + return '[Circular reference]'; + } + + // Mark object as seen + if (typeof value === 'object') { + seen.add(value); + } + + // Handle arrays + if (Array.isArray(value)) { + return value.map(item => serializeValue(item, depth + 1, maxDepth, seen)); + } + + // Handle NodeList and HTMLCollection + if (value instanceof NodeList || value instanceof HTMLCollection) { + return { + nodeType: value instanceof NodeList ? 'NodeList' : 'HTMLCollection', + length: value.length, + items: Array.from(value).map(item => serializeValue(item, depth + 1, maxDepth, seen)) + }; + } + + // Handle typed arrays + if (ArrayBuffer.isView(value)) { + return { + type: value.constructor.name, + length: value.length, + data: '[Binary data]' + }; + } + + // Handle other objects + if (typeof value === 'object') { + const result = {}; + const keys = Object.keys(value); + + // Limit number of keys to prevent huge objects + const maxKeys = 100; + const limitedKeys = keys.slice(0, maxKeys); + + for (const key of limitedKeys) { + try { + const serialized = this.serializeValue(value[key], depth + 1, maxDepth, seen); + if (serialized === undefined || serialized === null) continue; // Skip undefined values + result[key] = serialized; + } catch (e) { + result[key] = '[Error accessing property]'; + } + } + + if (keys.length > maxKeys) { + result['...'] = `${keys.length - maxKeys} more properties`; + } + + return result; + } + + // Fallback for unknown types + return String(value); +} +function getTabInfo() { + const de = document.documentElement; + return { + url: window.location.href, + title: document.title, + domSize: de.outerHTML.length, + fullPageDimensions: { width: de.scrollWidth, height: de.scrollHeight }, + viewportDimensions: { width: window.innerWidth, height: window.innerHeight }, + scrollPosition: { x: window.pageXOffset || de.scrollLeft, y: window.pageYOffset || de.scrollTop }, + pageVisibility: { visible: !document.hidden, visibilityState: document.visibilityState } + }; +} +function findAllElements(selector, xpath) { + if (selector) { + try { + return Array.from(document.querySelectorAll(selector)); + } catch (e) { + throw new Error(`Invalid selector: ${e.message}`); + } + } + try { + const result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + return Array.from({length: result.snapshotLength}, (_, i) => result.snapshotItem(i)); + } + catch (e) { + throw new Error(`Invalid XPath: ${e.message}`); + } +} +function getElementData(element) { + const rect = element.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(element); + + // Get the selector (which may add an ID to the element) + const selector = getUniqueSelector(element); + + // Comprehensive visibility check + const visible = isElementVisible(element, rect, computedStyle); + + const data = { + tagName: element.tagName.toLowerCase(), + id: element.id || undefined, + className: element.className || undefined, + selector: selector, + bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + visible: visible, + focused: element === document.activeElement, + position: computedStyle.position + }; + // Conditionally add attributes + ["href", "src", "value", "name"].forEach(attr => element[attr] && (data[attr] = element[attr])); + + // If it's a select element, add the options + if (data.tagName === 'select') { + data.options = Array.from(element.options).map((option, optionIndex) => ({ + index: optionIndex, + value: option.value, + text: option.text, + selected: option.selected, + disabled: option.disabled + })); + } + // Add scrollable parent if exists + const scrollParent = findScrollableParent(element); + data.scrollParent = getUniqueSelector(scrollParent); + return data; +} +function isElementVisible(element, rect, computedStyle) { + // If rect and computedStyle not provided, calculate them + if (!rect) rect = element.getBoundingClientRect(); + if (!computedStyle) computedStyle = window.getComputedStyle(element); + + // Check if element has dimensions + if (rect.width <= 0 || rect.height <= 0) return false; + + // Check CSS visibility properties + if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden' || computedStyle.opacity === '0') { + return false; + } + + // Check if element is in viewport + const viewportWidth = window.innerWidth || document.documentElement.clientWidth; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + + // Check if any part of the element is within the viewport + const inViewport = rect.bottom > 0 && + rect.right > 0 && + rect.top < viewportHeight && + rect.left < viewportWidth; + + if (!inViewport) return false; + + // Get element's center point (used multiple times below) + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // Helper function to check if element is visible at a point + const isElementAtPoint = (x, y) => { + const elementAtPoint = document.elementFromPoint(x, y); + if (!elementAtPoint) return false; + return elementAtPoint === element || element.contains(elementAtPoint) || elementAtPoint.contains(element); + }; + + // Check if element is visible at center point - this is the most reliable check + if (isElementAtPoint(centerX, centerY)) return true; + + // Check if element is hidden by ancestor's properties + let parent = element.parentElement; + while (parent && parent !== document.body) { + const parentStyle = window.getComputedStyle(parent); + if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden' || parentStyle.opacity === '0') { + return false; + } + + // Check for overflow hidden that might hide the element + if (parentStyle.overflow === 'hidden' || parentStyle.overflowX === 'hidden' || parentStyle.overflowY === 'hidden') { + const parentRect = parent.getBoundingClientRect(); + // Check if element is outside parent's visible area + if (rect.bottom < parentRect.top || rect.top > parentRect.bottom || rect.right < parentRect.left || rect.left > parentRect.right) { + // Before returning false, check if the element is actually visible using elementsFromPoint + const elementsAtPoint = document.elementsFromPoint(centerX, centerY); + + // If the element is in the elements chain at its center point, it's visible + if (elementsAtPoint.includes(element)) { + continue; // Skip this parent check and continue checking other parents + } + + return false; + } + } + parent = parent.parentElement; + } + + // Element might be partially covered, check multiple points + const points = [ + { x: rect.left + rect.width * 0.1, y: rect.top + rect.height * 0.1 }, + { x: rect.right - rect.width * 0.1, y: rect.top + rect.height * 0.1 }, + { x: rect.left + rect.width * 0.1, y: rect.bottom - rect.height * 0.1 }, + { x: rect.right - rect.width * 0.1, y: rect.bottom - rect.height * 0.1 } + ]; + + // Check if any of the points hit our element + return points.some(point => isElementAtPoint(point.x, point.y)); +} + +function respondWith(obj, selector, xpath) { + return { + success: !obj.error, + selector, + xpath: !selector ? xpath : undefined, + ...getTabInfo(), + ...obj + }; +} +function respondWithError(code, message, selector, xpath) { + return respondWith({ error: { code, message } }, selector, xpath); +} +function elementNotFound(selector, xpath) { + return respondWithError('ELEMENT_NOT_FOUND', 'Element not found', selector, xpath); +} +function requireSelectorOrXpath(selector, xpath) { + return respondWithError('SELECTOR_OR_XPATH_REQUIRED', 'Selector or XPath parameter required', selector, xpath); +} + +const helpers = { + //called by the background script + _navigate: ({ url }) => { + window.location.href = url; + }, + _elementPosition: ({ id }) => { + const element = document.getElementById(id); + return element.getBoundingClientRect(); + }, + _connectionStateChanged: ({ status, connected }) => { + // Remove existing connection classes + document.body.classList.remove('kapture-connected', 'kapture-connecting'); + + // Add appropriate class based on status + if (status === 'connected') { + document.body.classList.add('kapture-connected'); + } else if (status === 'retrying') { + document.body.classList.add('kapture-connecting'); + } + // No class for disconnected state + + return { success: true }; + }, + + // tool calls + getTabInfo, + + /** + * Limits the nesting depth of HTML by replacing deeply nested elements with comments + * indicating how many more layers exist. + */ + limitDOMNesting: (htmlString, maxNesting) => { + if (typeof maxNesting !== 'number' || maxNesting < 0) { + return htmlString; + } + + // Parse the HTML string + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/html'); + + // Get the root element (could be body or a specific element) + const root = doc.body.firstElementChild || doc.body; + + /** + * Counts the maximum nesting depth from a given element + */ + function countDepth(element) { + if (!element || !element.children || element.children.length === 0) { + return 0; + } + let maxChildDepth = 0; + for (const child of element.children) { + maxChildDepth = Math.max(maxChildDepth, countDepth(child)); + } + return 1 + maxChildDepth; + } + + /** + * Recursively processes elements and limits nesting depth + */ + function processElement(element, currentDepth) { + if (!element || !element.children) return; + + // If we've reached the max nesting depth + if (currentDepth >= maxNesting) { + // Count how many more layers exist + const remainingDepth = countDepth(element); + + if (remainingDepth > 0) { + // Replace all children with a comment + const comment = doc.createComment(` ... ${remainingDepth} more layer${remainingDepth === 1 ? '' : 's'} of nesting `); + + // Remove all children + while (element.firstChild) { + element.removeChild(element.firstChild); + } + + // Add the comment + element.appendChild(comment); + } + return; + } + + // Process children recursively + const children = Array.from(element.children); + for (const child of children) { + processElement(child, currentDepth + 1); + } + } + + // Start processing from depth 0 + processElement(root, 0); + + // Return the serialized HTML + return root.outerHTML; + }, + + dom: ({selector, xpath, maxNesting}) => { + let html; + + if (!selector && !xpath) { + html = document.body.outerHTML; + } else { + const element = findAllElements(selector, xpath)[0]; + if (!element) return elementNotFound(selector, xpath); + html = element.outerHTML; + } + + // Apply nesting limit (default is 5) + const nestingLimit = typeof maxNesting === 'number' && maxNesting >= 0 ? maxNesting : 5; + html = window.pageHelpers.limitDOMNesting(html, nestingLimit); + + return respondWith({ html }, selector, xpath); + }, + elementsFromPoint: ({x, y}) => { + if (typeof x !== 'number' || typeof y !== 'number') { + return respondWithError('XY_REQUIRED', 'Both x and y coordinates are required'); + } + const elements = document.elementsFromPoint(x, y); + return respondWith({ x, y, elements: elements.map(getElementData) }); + }, + elements: ({selector, xpath, visible = 'all'}) => { + if (!selector && !xpath) return requireSelectorOrXpath(); + + let elements; + try { + elements = findAllElements(selector, xpath).map(getElementData); + } catch (e) { + const errorCode = selector ? 'INVALID_SELECTOR' : 'INVALID_XPATH'; + return respondWithError(errorCode, e.message, selector, xpath); + } + + // Apply visibility filter + if (visible !== 'all') { + const filterVisible = String(visible) === 'true'; + elements = elements.filter(el => el.visible === filterVisible); + } + return respondWith({elements: elements, visible: visible !== 'all' ? visible : undefined}, selector, xpath); + }, + element: ({selector, xpath, visible = 'all'}) => { + const result = helpers.elements({selector, xpath, visible}); + if (result.error) return result; + if (!result.elements.length) return elementNotFound(selector, xpath); + result.element = result.elements[0]; + delete result.elements; + return result; + }, + focus: ({selector, xpath}) => { + if (!selector && !xpath) return requireSelectorOrXpath(); + + let element; + try { + element = findAllElements(selector, xpath)[0]; + } catch (e) { + const errorCode = selector ? 'INVALID_SELECTOR' : 'INVALID_XPATH'; + return respondWithError(errorCode, e.message, selector, xpath); + } + + if (!element) return elementNotFound(selector, xpath); + + // Focus the element + element.focus(); + + // Check if element is actually focusable + const focusableElements = ['input', 'textarea', 'select', 'button', 'a']; + const tagName = element.tagName.toLowerCase(); + const isFocusable = focusableElements.includes(tagName) || + element.hasAttribute('tabindex') || + element.isContentEditable; + + if (!isFocusable) { + // Still return success but with a warning + return respondWith({ + focused: true, + warning: 'Element may not be focusable' + }, selector, xpath); + } + + return respondWith({ focused: true }, selector, xpath); + }, + fill: ({selector, xpath, value}) => { + if (!selector && !xpath) return requireSelectorOrXpath(); + + const element = findAllElements(selector, xpath)[0]; + if (!element) return elementNotFound(selector, xpath); + + // Check if it's an input element + const tagName = element.tagName.toLowerCase(); + const inputTypes = ['input', 'textarea']; + + if (!inputTypes.includes(tagName) && !element.isContentEditable) { + return respondWithError('INVALID_ELEMENT', 'Element is not fillable: ' + tagName, selector, xpath); + } + + // Focus the element + element.focus(); + + // Clear existing value + if (element.value !== undefined) { + element.value = ''; + } else if (element.isContentEditable) { + element.textContent = ''; + } + + // Set new value + if (element.value !== undefined) { + element.value = value; + } else if (element.isContentEditable) { + element.textContent = value; + } + + // Trigger input and change events + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + + // Blur to trigger any blur handlers + element.blur(); + + return respondWith({ filled: true }, selector, xpath); + }, + select: ({selector, xpath, value}) => { + if (!selector && !xpath) return requireSelectorOrXpath(); + + const element = findAllElements(selector, xpath)[0]; + if (!element) return elementNotFound(selector, xpath); + + if (element.tagName !== 'SELECT') { + return respondWithError('INVALID_ELEMENT', 'Element is not fillable: ' + element.name, selector, xpath); + } + + // Find option by value + const option = Array.from(element.options).find(opt => opt.value === value); + if (!option) { + return respondWithError('OPTION_NOT_FOUND', 'Option not found with value: ' + value, selector, xpath); + } + + // Select the option + element.value = value; + option.selected = true; + + // Trigger change event + element.dispatchEvent(new Event('change', { bubbles: true })); + + return respondWith({ selected: true }, selector, xpath); + }, + blur: ({selector, xpath}) => { + if (!selector && !xpath) return requireSelectorOrXpath(); + + let element; + try { + element = findAllElements(selector, xpath)[0]; + } catch (e) { + const errorCode = selector ? 'INVALID_SELECTOR' : 'INVALID_XPATH'; + return respondWithError(errorCode, e.message, selector, xpath); + } + + if (!element) return elementNotFound(selector, xpath); + + // Blur the element + element.blur(); + + // Also remove focus from document.activeElement if it's different + if (document.activeElement && document.activeElement !== element) { + document.activeElement.blur(); + } + + return respondWith({ blurred: true }, selector, xpath); + }, + _cursor: ({show}) => { + const cursorId = 'kapture-cursor'; + let cursor = document.getElementById(cursorId); + + try { + if (show === false) { + // Hide cursor + if (cursor) { + cursor.style.display = 'none'; + } + return respondWith({ visible: false }); + } + + // Show cursor - create if doesn't exist + if (!cursor) { + cursor = document.createElement('div'); + cursor.id = cursorId; + + // Create cursor SVG + cursor.innerHTML = ` + + + + `; + + // Style the cursor container + cursor.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 20px; + height: 20px; + z-index: 2147483647; + pointer-events: none; + transform: translate(-2px, -2px); + transition: none; + will-change: transform; + `; + + document.body.appendChild(cursor); + } + + cursor.style.display = 'block'; + return respondWith({ visible: true }); + } catch (e) { + return respondWithError('CURSOR_ERROR', e.message); + } + }, + _moveMouseSVG: ({x, y}) => { + if (typeof x !== 'number' || typeof y !== 'number') { + return respondWithError('XY_REQUIRED', 'Both x and y coordinates are required'); + } + + try { + const cursor = document.getElementById('kapture-cursor'); + if (!cursor) { + return respondWithError('CURSOR_NOT_FOUND', 'Cursor element not found. Call _cursor with show=true first'); + } + + cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`; + return respondWith({ moved: true, x, y }); + } catch (e) { + return respondWithError('MOVE_MOUSE_SVG_ERROR', e.message); + } + } +}; + +// Mouse position tracking with throttling +let lastMouseSendTime = 0; +const MOUSE_THROTTLE_MS = 50; // Throttle to 20 updates per second + +document.addEventListener('mousemove', (event) => { + const now = Date.now(); + if (now - lastMouseSendTime < MOUSE_THROTTLE_MS) return; + + lastMouseSendTime = now; + chrome.runtime.sendMessage({ + type: 'mousePosition', + x: event.clientX, + y: event.clientY + }); +}); + +// Listen for requests from the extension +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (!request.command) return; + if (helpers[request.command]) { + const result = helpers[request.command](request.params); + Promise.resolve(result).then(sendResponse); + return true; // Keep channel open for async response + } + else { + sendResponse(respondWith({ + error: { code: 'UNKNOWN_COMMAND', message: `Command '${request.command}' not found` } + })); + } +}); diff --git a/extension-kapture/panel.css b/extension-kapture/panel.css new file mode 100644 index 0000000..8dce45e --- /dev/null +++ b/extension-kapture/panel.css @@ -0,0 +1,377 @@ +:root { + --bg-primary: #1e1e1e; + --bg-secondary: #252526; + --bg-tertiary: #2d2d30; + --text-primary: #cccccc; + --text-secondary: #969696; + --border-color: #464647; + --accent-blue: #3794ff; + --accent-green: #4ec9b0; + --accent-orange: #ff9800; + --accent-red: #f44336; + --hover-bg: #2a2a2a; + --selection-bg: #094771; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; + font-size: 13px; + background: var(--bg-primary); + color: var(--text-primary); + overflow: hidden; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +/* Connection Toggle */ +.connection-toggle { + display: flex; + align-items: center; + gap: 12px; +} + +/* Status */ +.status { + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.status.disconnected { + color: #8ab4f8; +} + +.status.retrying { + color: #fdd663; +} + +.status.connected { + color: #81c995; +} + +/* Toggle switch container */ +.toggle-container { + position: relative; + width: 48px; + height: 24px; + flex-shrink: 0; +} + +/* Hidden checkbox */ +.toggle-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +/* Toggle switch track */ +.toggle-track { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #5f6368; + transition: all 0.3s ease; + border-radius: 24px; +} + +/* Toggle switch thumb */ +.toggle-track::before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: #e8eaed; + transition: all 0.3s ease; + border-radius: 50%; +} + +/* Checked state */ +.toggle-input:checked + .toggle-track { + background-color: #81c995; +} + +.toggle-input:checked + .toggle-track::before { + transform: translateX(24px); +} + +/* Retrying state - must come after checked state to override */ +.retrying .toggle-input:checked + .toggle-track { + background-color: #5f5034; +} + +.retrying .toggle-input:checked + .toggle-track::before { + background-color: #fdd663; +} + +/* Spinner */ +.spinner { + width: 12px; + height: 12px; + border: 2px solid rgba(253, 214, 99, 0.3); + border-top-color: #fdd663; + border-radius: 50%; + animation: spin 1s linear infinite; + display: none; +} + +.status.retrying .spinner { + display: inline-block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.tab-info { + color: var(--text-secondary); + font-size: 12px; +} + +/* Messages Container */ +.messages-container { + flex: 1; + display: flex; + flex-direction: column; + height: calc(100vh - 120px); +} + +.messages-header { + display: flex; + padding: 8px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + font-size: 12px; + color: var(--text-secondary); +} + +.messages-header-data { + flex: 1; +} + +.messages-header-time { + width: 80px; + text-align: right; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.clear-messages-button { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.clear-messages-button:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} + +/* Messages List */ +.messages-list { + flex: 1; + overflow-y: auto; + background: var(--bg-primary); +} + +.message { + display: flex; + padding: 6px 12px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: background 0.1s; +} + +.message:hover { + background: var(--hover-bg); +} + +.message.selected { + background: var(--selection-bg); +} + +.message-data { + flex: 1; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.message-arrow { + margin-right: 8px; + font-weight: bold; +} + +.message-arrow.outgoing { + color: var(--accent-blue); +} + +.message-arrow.incoming { + color: var(--accent-green); +} + +.message-time { + width: 80px; + text-align: right; + color: var(--text-secondary); + font-size: 11px; +} + +.empty-state { + align-items: center; + justify-content: center; + height: 200px; + color: var(--text-secondary); + font-style: italic; +} + +/* Show/hide based on parent class */ +.messages-container .empty-state { + display: flex; +} + +.messages-container .messages-list { + display: none; +} + +.messages-container.has-messages .empty-state { + display: none; +} + +.messages-container.has-messages .messages-list { + display: block; +} + +/* Detail View */ +.detail-container { + height: 200px; + display: none; + flex-direction: column; + border-top: 1px solid var(--border-color); +} + +.detail-container.visible { + display: flex; +} + +.resize-handle { + height: 4px; + background: var(--border-color); + cursor: ns-resize; + user-select: none; +} + +.resize-handle:hover { + background: var(--accent-blue); +} + +.detail-content { + flex: 1; + padding: 12px; + overflow: auto; + background: var(--bg-secondary); + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + font-size: 12px; +} + +.detail-content pre { + margin: 0; + white-space: pre; +} + +.detail-content img { + max-width: 100%; + height: auto; +} + +/* Footer */ +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 8px 12px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); +} + +.console-count { + color: var(--text-secondary); + font-size: 12px; +} + +.clear-button { + padding: 4px 12px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; + transition: all 0.2s; +} + +.clear-button:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +/* Layout */ +.panel-container { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} \ No newline at end of file diff --git a/extension-kapture/panel.html b/extension-kapture/panel.html new file mode 100644 index 0000000..578cf3c --- /dev/null +++ b/extension-kapture/panel.html @@ -0,0 +1,56 @@ + + + + + + +
+ +
+ Tab: Not connected +
+
+ + Disconnected +
+ +
+
+ + +
+
+
Date
+
+ Time + +
+
+ +
No messages yet
+
+ + +
+
+
+
+
+ + + +
+ + + + diff --git a/extension-kapture/panel.js b/extension-kapture/panel.js new file mode 100644 index 0000000..805f1f5 --- /dev/null +++ b/extension-kapture/panel.js @@ -0,0 +1,304 @@ +// DevTools Panel UI with Real Connection and Data + +// Debug what you're actually getting +console.log('inspectedWindow.tabId:', chrome.devtools.inspectedWindow.tabId); + +// Let's also check what chrome.tabs.query gives us for comparison +chrome.tabs.query({}, (tabs) => { + console.log('All tabs:', tabs); + + // Find the tab with the same ID + const inspectedTab = tabs.find(tab => tab.id === chrome.devtools.inspectedWindow.tabId); + console.log('Inspected tab details:', inspectedTab); +}); + +// Also check the inspected window URL +console.log('inspectedWindow.url:', chrome.devtools.inspectedWindow.url); + +const tabId = chrome.devtools.inspectedWindow.tabId; +let selectedMessageIndex = -1; +let messages = []; +let consoleLogCount = 0; +let port = null; + +// Initialize UI +function initializeUI() { + // Connect to background script + port = chrome.runtime.connect({ name: 'panel' }); + + // Listen for state updates + port.onMessage.addListener((msg) => { + if (msg.type === 'state' && msg.tabId === tabId) { + updateUI(msg.connected, msg.status); + } else if (msg.type === 'messages' && msg.tabId === tabId) { + // Update messages from background + messages = msg.messages || []; + renderMessages(); + } else if (msg.type === 'consoleCount' && msg.tabId === tabId) { + // Update console count from background + consoleLogCount = msg.count || 0; + updateConsoleCount(); + } + }); + + // Subscribe to state updates for this tab + port.postMessage({ type: 'subscribe', tabId }); + + // Event listeners + document.getElementById('toggle').addEventListener('change', handleToggleChange); + document.getElementById('clear-logs').addEventListener('click', handleClearLogs); + document.getElementById('clear-messages').addEventListener('click', handleClearMessages); + document.getElementById('messages-list').addEventListener('click', handleMessageClick); + document.addEventListener('keydown', handleKeyDown); + + // Resize handle + initializeResizeHandle(); +} + +// Update UI based on connection state +function updateUI(connected, status = 'disconnected') { + const toggle = document.getElementById('toggle'); + const toggleContainer = toggle.parentElement; + const statusEl = document.getElementById('status'); + const statusText = statusEl.querySelector('.status-text'); + const tabInfo = document.getElementById('tab-info'); + + // Remove existing classes + statusEl.classList.remove('connected', 'disconnected', 'retrying'); + toggleContainer.classList.remove('connected', 'disconnected', 'retrying'); + + switch (status) { + case 'connected': + toggle.checked = true; + toggle.disabled = false; + statusEl.classList.add('connected'); + toggleContainer.classList.add('connected'); + statusText.textContent = 'Connected'; + tabInfo.textContent = `Tab: ${tabId} - Connected`; + break; + + case 'retrying': + toggle.checked = true; + toggle.disabled = false; + statusEl.classList.add('retrying'); + toggleContainer.classList.add('retrying'); + statusText.textContent = 'Retrying...'; + tabInfo.textContent = `Tab: ${tabId} - Reconnecting`; + break; + + case 'disconnected': + default: + toggle.checked = false; + toggle.disabled = false; + statusEl.classList.add('disconnected'); + toggleContainer.classList.add('disconnected'); + statusText.textContent = 'Disconnected'; + tabInfo.textContent = 'Tab: Not connected'; + break; + } +} + +// Render messages +function renderMessages() { + const messagesList = document.getElementById('messages-list'); + const messagesContainer = document.querySelector('.messages-container'); + + // Toggle class based on whether we have messages + messagesContainer.classList.toggle('has-messages', messages.length > 0); + + if (messages.length === 0) { + return; + } + + messagesList.innerHTML = ''; + + messages.forEach((msg, index) => { + const messageEl = document.createElement('div'); + messageEl.className = 'message'; + if (index === selectedMessageIndex) { + messageEl.classList.add('selected'); + } + + const arrow = msg.direction === 'outgoing' ? '⬆' : '⬇'; // Up arrow for outgoing, down arrow for incoming + const arrowClass = msg.direction === 'outgoing' ? 'outgoing' : 'incoming'; + + // Show the raw JSON data + const dataText = JSON.stringify(msg.data); + + messageEl.innerHTML = ` +
+ ${arrow} + ${dataText} +
+
${formatTime(msg.timestamp)}
+ `; + + messageEl.dataset.index = index; + messagesList.appendChild(messageEl); + }); + + // Scroll to bottom + messagesList.scrollTop = messagesList.scrollHeight; +} + +// Format timestamp +function formatTime(date) { + // Convert string to Date if needed + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateObj.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +// Handle message click +function handleMessageClick(e) { + const messageEl = e.target.closest('.message'); + if (!messageEl) return; + + const index = parseInt(messageEl.dataset.index); + selectMessage(index); +} + +// Select message +function selectMessage(index) { + selectedMessageIndex = index; + + // Update selected state + document.querySelectorAll('.message').forEach((el, i) => { + el.classList.toggle('selected', i === index); + }); + + // Show detail view + const detailContainer = document.getElementById('detail-container'); + const detailContent = document.getElementById('detail-content'); + + if (index >= 0 && index < messages.length) { + detailContainer.classList.add('visible'); + const message = messages[index]; + const data = message.data; + + // Check if this is a screenshot response + if (data.type === 'response' && data.success && data.result && data.result.data && + data.result.mimeType && data.result.mimeType.startsWith('image/')) { + // Create image preview for screenshot + const img = document.createElement('img'); + img.src = `data:${data.result.mimeType};base64,${data.result.data}`; + img.style.cssText = 'max-width: 100%; height: auto; cursor: pointer; display: block; border: 1px solid #e0e0e0; border-radius: 4px;'; + img.title = 'Click to open in new tab'; + + // Open in new tab on click + img.addEventListener('click', () => { + window.open(img.src, '_blank'); + }); + + // Show image and data + detailContent.innerHTML = ''; + detailContent.appendChild(img); + + // Add the rest of the data below the image + const dataInfo = document.createElement('pre'); + dataInfo.textContent = JSON.stringify(data.result, null, 2); + dataInfo.style.cssText = 'margin-top: 10px; font-size: 12px; overflow: auto;'; + detailContent.appendChild(dataInfo); + } else { + // Show only the actual message data, not the wrapper + detailContent.innerHTML = ''; + const pre = document.createElement('pre'); + pre.style.cssText = 'margin: 0; font-size: 12px; overflow: auto;'; + pre.textContent = JSON.stringify(message.data, null, 2); + detailContent.appendChild(pre); + } + } else { + detailContainer.classList.remove('visible'); + } +} + +// Handle keyboard navigation +function handleKeyDown(e) { + if (e.key === 'ArrowUp' && selectedMessageIndex > 0) { + selectMessage(selectedMessageIndex - 1); + e.preventDefault(); + } else if (e.key === 'ArrowDown' && selectedMessageIndex < messages.length - 1) { + selectMessage(selectedMessageIndex + 1); + e.preventDefault(); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + // Clear messages + handleClearMessages(); + e.preventDefault(); + } +} + +// Handle toggle change +function handleToggleChange(e) { + const checked = e.target.checked; + + chrome.runtime.sendMessage( + { + type: checked ? 'connect' : 'disconnect', + tabId: tabId + }, + (response) => { + if (response?.error) { + console.error('Toggle error:', response.error); + } + } + ); +} + +// Handle clear logs +function handleClearLogs() { + // Request background to clear console logs + port.postMessage({ type: 'clearConsoleLogs', tabId }); +} + +// Handle clear messages +function handleClearMessages() { + // Request background to clear messages + port.postMessage({ type: 'clearMessages', tabId }); + + // Clear the detail view + const detailContainer = document.getElementById('detail-container'); + detailContainer.classList.remove('visible'); + selectedMessageIndex = -1; +} + +// Update console count +function updateConsoleCount() { + document.getElementById('console-count').textContent = `Console: ${consoleLogCount}`; +} + +// Initialize resize handle +function initializeResizeHandle() { + const resizeHandle = document.getElementById('resize-handle'); + const detailContainer = document.getElementById('detail-container'); + let isResizing = false; + let startY = 0; + let startHeight = 0; + + resizeHandle.addEventListener('mousedown', (e) => { + isResizing = true; + startY = e.clientY; + startHeight = detailContainer.offsetHeight; + document.body.style.cursor = 'ns-resize'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + + const deltaY = startY - e.clientY; + const newHeight = Math.min(Math.max(100, startHeight + deltaY), 500); + detailContainer.style.height = `${newHeight}px`; + }); + + document.addEventListener('mouseup', () => { + isResizing = false; + document.body.style.cursor = ''; + }); +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', initializeUI); diff --git a/extension-kapture/popup.html b/extension-kapture/popup.html new file mode 100644 index 0000000..ddc805d --- /dev/null +++ b/extension-kapture/popup.html @@ -0,0 +1,130 @@ + + + + + + +
+ + Disconnected +
+ + + + \ No newline at end of file diff --git a/extension-kapture/popup.js b/extension-kapture/popup.js new file mode 100644 index 0000000..8716435 --- /dev/null +++ b/extension-kapture/popup.js @@ -0,0 +1,80 @@ +// Popup UI + +let tabId; +let port; +let isUpdatingUI = false; + +// Get current tab and set up +chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + tabId = tabs[0].id; + + // Connect to background + port = chrome.runtime.connect(); + port.postMessage({ type: 'subscribe', tabId }); + + // Listen for state updates + port.onMessage.addListener((msg) => { + if (msg.type === 'state' && msg.tabId === tabId) { + updateUI(msg.connected, msg.status); + } + }); + + // Get initial state + chrome.runtime.sendMessage({ type: 'getState', tabId }, (state) => { + updateUI(state.connected, state.status); + }); +}); + +// Handle toggle switch +document.getElementById('toggle').addEventListener('change', (e) => { + if (isUpdatingUI) return; // Prevent feedback loop + + if (e.target.checked) { + chrome.runtime.sendMessage({ type: 'connect', tabId }); + } else { + chrome.runtime.sendMessage({ type: 'disconnect', tabId }); + } +}); + +// Update UI +function updateUI(connected, status = 'disconnected') { + isUpdatingUI = true; + + const toggle = document.getElementById('toggle'); + const toggleContainer = toggle.parentElement; + const statusEl = document.getElementById('status'); + const statusText = statusEl.querySelector('.status-text'); + + // Remove all state classes + statusEl.classList.remove('connected', 'disconnected', 'retrying'); + toggleContainer.classList.remove('connected', 'disconnected', 'retrying'); + + switch (status) { + case 'connected': + toggle.checked = true; + toggle.disabled = false; + statusEl.classList.add('connected'); + toggleContainer.classList.add('connected'); + statusText.textContent = 'Connected'; + break; + + case 'retrying': + toggle.checked = true; + toggle.disabled = false; + statusEl.classList.add('retrying'); + toggleContainer.classList.add('retrying'); + statusText.textContent = 'Connecting'; + break; + + case 'disconnected': + default: + toggle.checked = false; + toggle.disabled = false; + statusEl.classList.add('disconnected'); + toggleContainer.classList.add('disconnected'); + statusText.textContent = 'Disconnected'; + break; + } + + setTimeout(() => { isUpdatingUI = false; }, 100); +} \ No newline at end of file diff --git a/extension-playwright/README.md b/extension-playwright/README.md new file mode 100644 index 0000000..6421798 --- /dev/null +++ b/extension-playwright/README.md @@ -0,0 +1,48 @@ +# Playwright MCP Chrome Extension + +## Introduction + +The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup. + +## Prerequisites + +- Chrome/Edge/Chromium browser + +## Installation Steps + +### Download the Extension + +Download the latest Chrome extension from GitHub: +- **Download link**: https://github.com/microsoft/playwright-mcp/releases + +### Load Chrome Extension + +1. Open Chrome and navigate to `chrome://extensions/` +2. Enable "Developer mode" (toggle in the top right corner) +3. Click "Load unpacked" and select the extension directory + +### Configure Playwright MCP server + +Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server: + +```json +{ + "mcpServers": { + "playwright-extension": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--extension" + ] + } + } +} +``` + +## Usage + +### Browser Tab Selection + +When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session. + + diff --git a/extension-playwright/icons/icon-128.png b/extension-playwright/icons/icon-128.png new file mode 100644 index 0000000..c4bc8b0 Binary files /dev/null and b/extension-playwright/icons/icon-128.png differ diff --git a/extension-playwright/icons/icon-16.png b/extension-playwright/icons/icon-16.png new file mode 100644 index 0000000..0bab712 Binary files /dev/null and b/extension-playwright/icons/icon-16.png differ diff --git a/extension-playwright/icons/icon-32.png b/extension-playwright/icons/icon-32.png new file mode 100644 index 0000000..1f9a8cc Binary files /dev/null and b/extension-playwright/icons/icon-32.png differ diff --git a/extension-playwright/icons/icon-48.png b/extension-playwright/icons/icon-48.png new file mode 100644 index 0000000..ac23ef0 Binary files /dev/null and b/extension-playwright/icons/icon-48.png differ diff --git a/extension-playwright/manifest.json b/extension-playwright/manifest.json new file mode 100644 index 0000000..2efe81e --- /dev/null +++ b/extension-playwright/manifest.json @@ -0,0 +1,35 @@ +{ + "manifest_version": 3, + "name": "Playwright MCP Bridge", + "version": "0.0.46", + "description": "Share browser tabs with Playwright MCP server", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB", + "permissions": [ + "debugger", + "activeTab", + "tabs", + "storage" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "lib/background.mjs", + "type": "module" + }, + "action": { + "default_title": "Playwright MCP Bridge", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/extension-playwright/package-lock.json b/extension-playwright/package-lock.json new file mode 100644 index 0000000..313e31c --- /dev/null +++ b/extension-playwright/package-lock.json @@ -0,0 +1,1885 @@ +{ + "name": "@playwright/mcp-extension", + "version": "0.0.46", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@playwright/mcp-extension", + "version": "0.0.46", + "license": "Apache-2.0", + "devDependencies": { + "@types/chrome": "^0.0.315", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.8.2", + "vite": "^5.4.21", + "vite-plugin-static-copy": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.1.tgz", + "integrity": "sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.1.tgz", + "integrity": "sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.1.tgz", + "integrity": "sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.1.tgz", + "integrity": "sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.1.tgz", + "integrity": "sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.1.tgz", + "integrity": "sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.1.tgz", + "integrity": "sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.1.tgz", + "integrity": "sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.1.tgz", + "integrity": "sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.1.tgz", + "integrity": "sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.1.tgz", + "integrity": "sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.1.tgz", + "integrity": "sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.1.tgz", + "integrity": "sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.1.tgz", + "integrity": "sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.1.tgz", + "integrity": "sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.1.tgz", + "integrity": "sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.1.tgz", + "integrity": "sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.1.tgz", + "integrity": "sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.1.tgz", + "integrity": "sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.1.tgz", + "integrity": "sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/chrome": { + "version": "0.0.315", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz", + "integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==", + "dev": true, + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.192", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.192.tgz", + "integrity": "sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rollup": { + "version": "4.46.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz", + "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.1", + "@rollup/rollup-android-arm64": "4.46.1", + "@rollup/rollup-darwin-arm64": "4.46.1", + "@rollup/rollup-darwin-x64": "4.46.1", + "@rollup/rollup-freebsd-arm64": "4.46.1", + "@rollup/rollup-freebsd-x64": "4.46.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.1", + "@rollup/rollup-linux-arm-musleabihf": "4.46.1", + "@rollup/rollup-linux-arm64-gnu": "4.46.1", + "@rollup/rollup-linux-arm64-musl": "4.46.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.1", + "@rollup/rollup-linux-ppc64-gnu": "4.46.1", + "@rollup/rollup-linux-riscv64-gnu": "4.46.1", + "@rollup/rollup-linux-riscv64-musl": "4.46.1", + "@rollup/rollup-linux-s390x-gnu": "4.46.1", + "@rollup/rollup-linux-x64-gnu": "4.46.1", + "@rollup/rollup-linux-x64-musl": "4.46.1", + "@rollup/rollup-win32-arm64-msvc": "4.46.1", + "@rollup/rollup-win32-ia32-msvc": "4.46.1", + "@rollup/rollup-win32-x64-msvc": "4.46.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz", + "integrity": "sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "fs-extra": "^11.3.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.14" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/extension-playwright/package.json b/extension-playwright/package.json new file mode 100644 index 0000000..e37e112 --- /dev/null +++ b/extension-playwright/package.json @@ -0,0 +1,35 @@ +{ + "name": "@playwright/mcp-extension", + "version": "0.0.46", + "description": "Playwright MCP Browser Extension", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/playwright-mcp.git" + }, + "homepage": "https://playwright.dev", + "engines": { + "node": ">=18" + }, + "author": { + "name": "Microsoft Corporation" + }, + "license": "Apache-2.0", + "scripts": { + "build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && vite build --config vite.sw.config.mts", + "watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts", + "test": "playwright test", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@types/chrome": "^0.0.315", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.8.2", + "vite": "^5.4.21", + "vite-plugin-static-copy": "^3.1.1" + } +} diff --git a/extension-playwright/playwright.config.ts b/extension-playwright/playwright.config.ts new file mode 100644 index 0000000..7bf4f0e --- /dev/null +++ b/extension-playwright/playwright.config.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from '@playwright/test'; + +import type { TestOptions } from '../tests/fixtures'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + projects: [ + { name: 'chromium', use: { mcpBrowser: 'chromium' } }, + ], +}); diff --git a/extension-playwright/src/background.ts b/extension-playwright/src/background.ts new file mode 100644 index 0000000..e74ebb8 --- /dev/null +++ b/extension-playwright/src/background.ts @@ -0,0 +1,222 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RelayConnection, debugLog } from './relayConnection'; + +type PageMessage = { + type: 'connectToMCPRelay'; + mcpRelayUrl: string; +} | { + type: 'getTabs'; +} | { + type: 'connectToTab'; + tabId?: number; + windowId?: number; + mcpRelayUrl: string; +} | { + type: 'getConnectionStatus'; +} | { + type: 'disconnect'; +}; + +class TabShareExtension { + private _activeConnection: RelayConnection | undefined; + private _connectedTabId: number | null = null; + private _pendingTabSelection = new Map(); + + constructor() { + chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); + chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this)); + chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this)); + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + chrome.action.onClicked.addListener(this._onActionClicked.bind(this)); + } + + // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031 + private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { + switch (message.type) { + case 'connectToMCPRelay': + this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; + case 'getTabs': + this._getTabs().then( + tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; + case 'connectToTab': + const tabId = message.tabId || sender.tab?.id!; + const windowId = message.windowId || sender.tab?.windowId!; + this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; // Return true to indicate that the response will be sent asynchronously + case 'getConnectionStatus': + sendResponse({ + connectedTabId: this._connectedTabId + }); + return false; + case 'disconnect': + this._disconnect().then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; + } + return false; + } + + private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise { + try { + debugLog(`Connecting to relay at ${mcpRelayUrl}`); + const socket = new WebSocket(mcpRelayUrl); + await new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = () => reject(new Error('WebSocket error')); + setTimeout(() => reject(new Error('Connection timeout')), 5000); + }); + + const connection = new RelayConnection(socket); + connection.onclose = () => { + debugLog('Connection closed'); + this._pendingTabSelection.delete(selectorTabId); + // TODO: show error in the selector tab? + }; + this._pendingTabSelection.set(selectorTabId, { connection }); + debugLog(`Connected to MCP relay`); + } catch (error: any) { + const message = `Failed to connect to MCP relay: ${error.message}`; + debugLog(message); + throw new Error(message); + } + } + + private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise { + try { + debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`); + try { + this._activeConnection?.close('Another connection is requested'); + } catch (error: any) { + debugLog(`Error closing active connection:`, error); + } + await this._setConnectedTabId(null); + + this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection; + if (!this._activeConnection) + throw new Error('No active MCP relay connection'); + this._pendingTabSelection.delete(selectorTabId); + + this._activeConnection.setTabId(tabId); + this._activeConnection.onclose = () => { + debugLog('MCP connection closed'); + this._activeConnection = undefined; + void this._setConnectedTabId(null); + }; + + await Promise.all([ + this._setConnectedTabId(tabId), + chrome.tabs.update(tabId, { active: true }), + chrome.windows.update(windowId, { focused: true }), + ]); + debugLog(`Connected to MCP bridge`); + } catch (error: any) { + await this._setConnectedTabId(null); + debugLog(`Failed to connect tab ${tabId}:`, error.message); + throw error; + } + } + + private async _setConnectedTabId(tabId: number | null): Promise { + const oldTabId = this._connectedTabId; + this._connectedTabId = tabId; + if (oldTabId && oldTabId !== tabId) + await this._updateBadge(oldTabId, { text: '' }); + if (tabId) + await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' }); + } + + private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise { + try { + await chrome.action.setBadgeText({ tabId, text }); + await chrome.action.setTitle({ tabId, title: title || '' }); + if (color) + await chrome.action.setBadgeBackgroundColor({ tabId, color }); + } catch (error: any) { + // Ignore errors as the tab may be closed already. + } + } + + private async _onTabRemoved(tabId: number): Promise { + const pendingConnection = this._pendingTabSelection.get(tabId)?.connection; + if (pendingConnection) { + this._pendingTabSelection.delete(tabId); + pendingConnection.close('Browser tab closed'); + return; + } + if (this._connectedTabId !== tabId) + return; + this._activeConnection?.close('Browser tab closed'); + this._activeConnection = undefined; + this._connectedTabId = null; + } + + private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) { + for (const [tabId, pending] of this._pendingTabSelection) { + if (tabId === activeInfo.tabId) { + if (pending.timerId) { + clearTimeout(pending.timerId); + pending.timerId = undefined; + } + continue; + } + if (!pending.timerId) { + pending.timerId = setTimeout(() => { + const existed = this._pendingTabSelection.delete(tabId); + if (existed) { + pending.connection.close('Tab has been inactive for 5 seconds'); + chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' }); + } + }, 5000); + return; + } + } + } + + private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) { + if (this._connectedTabId === tabId) + void this._setConnectedTabId(tabId); + } + + private async _getTabs(): Promise { + const tabs = await chrome.tabs.query({}); + return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme))); + } + + private async _onActionClicked(): Promise { + await chrome.tabs.create({ + url: chrome.runtime.getURL('status.html'), + active: true + }); + } + + private async _disconnect(): Promise { + this._activeConnection?.close('User disconnected'); + this._activeConnection = undefined; + await this._setConnectedTabId(null); + } +} + +new TabShareExtension(); diff --git a/extension-playwright/src/relayConnection.ts b/extension-playwright/src/relayConnection.ts new file mode 100644 index 0000000..b203af4 --- /dev/null +++ b/extension-playwright/src/relayConnection.ts @@ -0,0 +1,178 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function debugLog(...args: unknown[]): void { + const enabled = true; + if (enabled) { + // eslint-disable-next-line no-console + console.log('[Extension]', ...args); + } +} + +type ProtocolCommand = { + id: number; + method: string; + params?: any; +}; + +type ProtocolResponse = { + id?: number; + method?: string; + params?: any; + result?: any; + error?: string; +}; + +export class RelayConnection { + private _debuggee: chrome.debugger.Debuggee; + private _ws: WebSocket; + private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void; + private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void; + private _tabPromise: Promise; + private _tabPromiseResolve!: () => void; + private _closed = false; + + onclose?: () => void; + + constructor(ws: WebSocket) { + this._debuggee = { }; + this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve); + this._ws = ws; + this._ws.onmessage = this._onMessage.bind(this); + this._ws.onclose = () => this._onClose(); + // Store listeners for cleanup + this._eventListener = this._onDebuggerEvent.bind(this); + this._detachListener = this._onDebuggerDetach.bind(this); + chrome.debugger.onEvent.addListener(this._eventListener); + chrome.debugger.onDetach.addListener(this._detachListener); + } + + // Either setTabId or close is called after creating the connection. + setTabId(tabId: number): void { + this._debuggee = { tabId }; + this._tabPromiseResolve(); + } + + close(message: string): void { + this._ws.close(1000, message); + // ws.onclose is called asynchronously, so we call it here to avoid forwarding + // CDP events to the closed connection. + this._onClose(); + } + + private _onClose() { + if (this._closed) + return; + this._closed = true; + chrome.debugger.onEvent.removeListener(this._eventListener); + chrome.debugger.onDetach.removeListener(this._detachListener); + chrome.debugger.detach(this._debuggee).catch(() => {}); + this.onclose?.(); + } + + private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { + if (source.tabId !== this._debuggee.tabId) + return; + debugLog('Forwarding CDP event:', method, params); + const sessionId = source.sessionId; + this._sendMessage({ + method: 'forwardCDPEvent', + params: { + sessionId, + method, + params, + }, + }); + } + + private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void { + if (source.tabId !== this._debuggee.tabId) + return; + this.close(`Debugger detached: ${reason}`); + this._debuggee = { }; + } + + private _onMessage(event: MessageEvent): void { + this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e)); + } + + private async _onMessageAsync(event: MessageEvent): Promise { + let message: ProtocolCommand; + try { + message = JSON.parse(event.data); + } catch (error: any) { + debugLog('Error parsing message:', error); + this._sendError(-32700, `Error parsing message: ${error.message}`); + return; + } + + debugLog('Received message:', message); + + const response: ProtocolResponse = { + id: message.id, + }; + try { + response.result = await this._handleCommand(message); + } catch (error: any) { + debugLog('Error handling command:', error); + response.error = error.message; + } + debugLog('Sending response:', response); + this._sendMessage(response); + } + + private async _handleCommand(message: ProtocolCommand): Promise { + if (message.method === 'attachToTab') { + await this._tabPromise; + debugLog('Attaching debugger to tab:', this._debuggee); + await chrome.debugger.attach(this._debuggee, '1.3'); + const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo'); + return { + targetInfo: result?.targetInfo, + }; + } + if (!this._debuggee.tabId) + throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.'); + if (message.method === 'forwardCDPCommand') { + const { sessionId, method, params } = message.params; + debugLog('CDP command:', method, params); + const debuggerSession: chrome.debugger.DebuggerSession = { + ...this._debuggee, + sessionId, + }; + // Forward CDP command to chrome.debugger + return await chrome.debugger.sendCommand( + debuggerSession, + method, + params + ); + } + } + + private _sendError(code: number, message: string): void { + this._sendMessage({ + error: { + code, + message, + }, + }); + } + + private _sendMessage(message: any): void { + if (this._ws.readyState === WebSocket.OPEN) + this._ws.send(JSON.stringify(message)); + } +} diff --git a/extension-playwright/src/ui/authToken.css b/extension-playwright/src/ui/authToken.css new file mode 100644 index 0000000..adfa30c --- /dev/null +++ b/extension-playwright/src/ui/authToken.css @@ -0,0 +1,142 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.auth-token-section { + margin: 16px 0; + padding: 16px; + background-color: #f6f8fa; + border-radius: 6px; +} + +.auth-token-description { + font-size: 12px; + color: #656d76; + margin-bottom: 12px; +} + +.auth-token-container { + display: flex; + align-items: center; + gap: 8px; + background-color: #ffffff; + padding: 8px; +} + +.auth-token-code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + color: #1f2328; + border: none; + flex: 1; + padding: 0; + word-break: break-all; +} + +.auth-token-refresh { + flex: none; + height: 24px; + width: 24px; + border: none; + outline: none; + color: var(--color-fg-muted); + background: transparent; + padding: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.auth-token-refresh svg { + margin: 0; +} + +.auth-token-refresh:not(:disabled):hover { + background-color: var(--color-btn-selected-bg); +} + +.auth-token-example-section { + margin-top: 16px; +} + +.auth-token-example-toggle { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + padding: 8px 0; + font-size: 12px; + color: #656d76; + cursor: pointer; + outline: none; + text-align: left; + width: 100%; +} + +.auth-token-example-toggle:hover { + color: #1f2328; +} + +.auth-token-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + transform: rotate(-90deg); + flex-shrink: 0; +} + +.auth-token-chevron.expanded { + transform: rotate(0deg); +} + +.auth-token-chevron svg { + width: 12px; + height: 12px; +} + +.auth-token-chevron .octicon { + margin: 0px; +} + +.auth-token-example-content { + margin-top: 12px; + padding: 12px 0; +} + +.auth-token-example-description { + font-size: 12px; + color: #656d76; + margin-bottom: 12px; +} + +.auth-token-example-config { + display: flex; + align-items: flex-start; + gap: 8px; + background-color: #ffffff; + padding: 12px; +} + +.auth-token-example-code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 11px; + color: #1f2328; + white-space: pre; + flex: 1; + line-height: 1.4; +} diff --git a/extension-playwright/src/ui/authToken.tsx b/extension-playwright/src/ui/authToken.tsx new file mode 100644 index 0000000..699cf2f --- /dev/null +++ b/extension-playwright/src/ui/authToken.tsx @@ -0,0 +1,118 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useState } from 'react'; +import { CopyToClipboard } from './copyToClipboard'; +import * as icons from './icons'; +import './authToken.css'; + +export const AuthTokenSection: React.FC<{}> = ({}) => { + const [authToken, setAuthToken] = useState(getOrCreateAuthToken); + const [isExampleExpanded, setIsExampleExpanded] = useState(false); + + const onRegenerateToken = useCallback(() => { + const newToken = generateAuthToken(); + localStorage.setItem('auth-token', newToken); + setAuthToken(newToken); + }, []); + + const toggleExample = useCallback(() => { + setIsExampleExpanded(!isExampleExpanded); + }, [isExampleExpanded]); + + return ( +
+
+ Set this environment variable to bypass the connection dialog: +
+
+ {authTokenCode(authToken)} + + +
+ +
+ + + {isExampleExpanded && ( +
+
+ Add this configuration to your MCP client (e.g., VS Code) to connect to the Playwright MCP Bridge: +
+
+ {exampleConfig(authToken)} + +
+
+ )} +
+
+ ); +}; + +function authTokenCode(authToken: string) { + return `PLAYWRIGHT_MCP_EXTENSION_TOKEN=${authToken}`; +} + +function exampleConfig(authToken: string) { + return `{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest", "--extension"], + "env": { + "PLAYWRIGHT_MCP_EXTENSION_TOKEN": + "${authToken}" + } + } + } +}`; +} + +function generateAuthToken(): string { + // Generate a cryptographically secure random token + const array = new Uint8Array(32); + crypto.getRandomValues(array); + // Convert to base64 and make it URL-safe + return btoa(String.fromCharCode.apply(null, Array.from(array))) + .replace(/[+/=]/g, match => { + switch (match) { + case '+': return '-'; + case '/': return '_'; + case '=': return ''; + default: return match; + } + }); +} + +export const getOrCreateAuthToken = (): string => { + let token = localStorage.getItem('auth-token'); + if (!token) { + token = generateAuthToken(); + localStorage.setItem('auth-token', token); + } + return token; +} diff --git a/extension-playwright/src/ui/colors.css b/extension-playwright/src/ui/colors.css new file mode 100644 index 0000000..42b254f --- /dev/null +++ b/extension-playwright/src/ui/colors.css @@ -0,0 +1,891 @@ +/* The MIT License (MIT) + +Copyright (c) 2021 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. */ + +:root { + --color-canvas-default-transparent: rgba(255,255,255,0); + --color-marketing-icon-primary: #218bff; + --color-marketing-icon-secondary: #54aeff; + --color-diff-blob-addition-num-text: #24292f; + --color-diff-blob-addition-fg: #24292f; + --color-diff-blob-addition-num-bg: #CCFFD8; + --color-diff-blob-addition-line-bg: #E6FFEC; + --color-diff-blob-addition-word-bg: #ABF2BC; + --color-diff-blob-deletion-num-text: #24292f; + --color-diff-blob-deletion-fg: #24292f; + --color-diff-blob-deletion-num-bg: #FFD7D5; + --color-diff-blob-deletion-line-bg: #FFEBE9; + --color-diff-blob-deletion-word-bg: rgba(255,129,130,0.4); + --color-diff-blob-hunk-num-bg: rgba(84,174,255,0.4); + --color-diff-blob-expander-icon: #57606a; + --color-diff-blob-selected-line-highlight-mix-blend-mode: multiply; + --color-diffstat-deletion-border: rgba(27,31,36,0.15); + --color-diffstat-addition-border: rgba(27,31,36,0.15); + --color-diffstat-addition-bg: #2da44e; + --color-search-keyword-hl: #fff8c5; + --color-prettylights-syntax-comment: #6e7781; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #8250df; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #FFEBE9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-codemirror-text: #24292f; + --color-codemirror-bg: #ffffff; + --color-codemirror-gutters-bg: #ffffff; + --color-codemirror-guttermarker-text: #ffffff; + --color-codemirror-guttermarker-subtle-text: #6e7781; + --color-codemirror-linenumber-text: #57606a; + --color-codemirror-cursor: #24292f; + --color-codemirror-selection-bg: rgba(84,174,255,0.4); + --color-codemirror-activeline-bg: rgba(234,238,242,0.5); + --color-codemirror-matchingbracket-text: #24292f; + --color-codemirror-lines-bg: #ffffff; + --color-codemirror-syntax-comment: #24292f; + --color-codemirror-syntax-constant: #0550ae; + --color-codemirror-syntax-entity: #8250df; + --color-codemirror-syntax-keyword: #cf222e; + --color-codemirror-syntax-storage: #cf222e; + --color-codemirror-syntax-string: #0a3069; + --color-codemirror-syntax-support: #0550ae; + --color-codemirror-syntax-variable: #953800; + --color-checks-bg: #24292f; + --color-checks-run-border-width: 0px; + --color-checks-container-border-width: 0px; + --color-checks-text-primary: #f6f8fa; + --color-checks-text-secondary: #8c959f; + --color-checks-text-link: #54aeff; + --color-checks-btn-icon: #afb8c1; + --color-checks-btn-hover-icon: #f6f8fa; + --color-checks-btn-hover-bg: rgba(255,255,255,0.125); + --color-checks-input-text: #eaeef2; + --color-checks-input-placeholder-text: #8c959f; + --color-checks-input-focus-text: #8c959f; + --color-checks-input-bg: #32383f; + --color-checks-input-shadow: none; + --color-checks-donut-error: #fa4549; + --color-checks-donut-pending: #bf8700; + --color-checks-donut-success: #2da44e; + --color-checks-donut-neutral: #afb8c1; + --color-checks-dropdown-text: #afb8c1; + --color-checks-dropdown-bg: #32383f; + --color-checks-dropdown-border: #424a53; + --color-checks-dropdown-shadow: rgba(27,31,36,0.3); + --color-checks-dropdown-hover-text: #f6f8fa; + --color-checks-dropdown-hover-bg: #424a53; + --color-checks-dropdown-btn-hover-text: #f6f8fa; + --color-checks-dropdown-btn-hover-bg: #32383f; + --color-checks-scrollbar-thumb-bg: #57606a; + --color-checks-header-label-text: #d0d7de; + --color-checks-header-label-open-text: #f6f8fa; + --color-checks-header-border: #32383f; + --color-checks-header-icon: #8c959f; + --color-checks-line-text: #d0d7de; + --color-checks-line-num-text: rgba(140,149,159,0.75); + --color-checks-line-timestamp-text: #8c959f; + --color-checks-line-hover-bg: #32383f; + --color-checks-line-selected-bg: rgba(33,139,255,0.15); + --color-checks-line-selected-num-text: #54aeff; + --color-checks-line-dt-fm-text: #24292f; + --color-checks-line-dt-fm-bg: #9a6700; + --color-checks-gate-bg: rgba(125,78,0,0.15); + --color-checks-gate-text: #d0d7de; + --color-checks-gate-waiting-text: #afb8c1; + --color-checks-step-header-open-bg: #32383f; + --color-checks-step-error-text: #ff8182; + --color-checks-step-warning-text: #d4a72c; + --color-checks-logline-text: #8c959f; + --color-checks-logline-num-text: rgba(140,149,159,0.75); + --color-checks-logline-debug-text: #c297ff; + --color-checks-logline-error-text: #d0d7de; + --color-checks-logline-error-num-text: #ff8182; + --color-checks-logline-error-bg: rgba(164,14,38,0.15); + --color-checks-logline-warning-text: #d0d7de; + --color-checks-logline-warning-num-text: #d4a72c; + --color-checks-logline-warning-bg: rgba(125,78,0,0.15); + --color-checks-logline-command-text: #54aeff; + --color-checks-logline-section-text: #4ac26b; + --color-checks-ansi-black: #24292f; + --color-checks-ansi-black-bright: #32383f; + --color-checks-ansi-white: #d0d7de; + --color-checks-ansi-white-bright: #d0d7de; + --color-checks-ansi-gray: #8c959f; + --color-checks-ansi-red: #ff8182; + --color-checks-ansi-red-bright: #ffaba8; + --color-checks-ansi-green: #4ac26b; + --color-checks-ansi-green-bright: #6fdd8b; + --color-checks-ansi-yellow: #d4a72c; + --color-checks-ansi-yellow-bright: #eac54f; + --color-checks-ansi-blue: #54aeff; + --color-checks-ansi-blue-bright: #80ccff; + --color-checks-ansi-magenta: #c297ff; + --color-checks-ansi-magenta-bright: #d8b9ff; + --color-checks-ansi-cyan: #76e3ea; + --color-checks-ansi-cyan-bright: #b3f0ff; + --color-project-header-bg: #24292f; + --color-project-sidebar-bg: #ffffff; + --color-project-gradient-in: #ffffff; + --color-project-gradient-out: rgba(255,255,255,0); + --color-mktg-success: rgba(36,146,67,1); + --color-mktg-info: rgba(19,119,234,1); + --color-mktg-bg-shade-gradient-top: rgba(27,31,36,0.065); + --color-mktg-bg-shade-gradient-bottom: rgba(27,31,36,0); + --color-mktg-btn-bg-top: hsla(228,82%,66%,1); + --color-mktg-btn-bg-bottom: #4969ed; + --color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1); + --color-mktg-btn-bg-overlay-bottom: #3355e0; + --color-mktg-btn-text: #ffffff; + --color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1); + --color-mktg-btn-primary-bg-bottom: #2ea44f; + --color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1); + --color-mktg-btn-primary-bg-overlay-bottom: #22863a; + --color-mktg-btn-primary-text: #ffffff; + --color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1); + --color-mktg-btn-enterprise-bg-bottom: #6f57ff; + --color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1); + --color-mktg-btn-enterprise-bg-overlay-bottom: #614eda; + --color-mktg-btn-enterprise-text: #ffffff; + --color-mktg-btn-outline-text: #4969ed; + --color-mktg-btn-outline-border: rgba(73,105,237,0.3); + --color-mktg-btn-outline-hover-text: #3355e0; + --color-mktg-btn-outline-hover-border: rgba(51,85,224,0.5); + --color-mktg-btn-outline-focus-border: #4969ed; + --color-mktg-btn-outline-focus-border-inset: rgba(73,105,237,0.5); + --color-mktg-btn-dark-text: #ffffff; + --color-mktg-btn-dark-border: rgba(255,255,255,0.3); + --color-mktg-btn-dark-hover-text: #ffffff; + --color-mktg-btn-dark-hover-border: rgba(255,255,255,0.5); + --color-mktg-btn-dark-focus-border: #ffffff; + --color-mktg-btn-dark-focus-border-inset: rgba(255,255,255,0.5); + --color-avatar-bg: #ffffff; + --color-avatar-border: rgba(27,31,36,0.15); + --color-avatar-stack-fade: #afb8c1; + --color-avatar-stack-fade-more: #d0d7de; + --color-avatar-child-shadow: -2px -2px 0 rgba(255,255,255,0.8); + --color-topic-tag-border: rgba(0,0,0,0); + --color-select-menu-backdrop-border: rgba(0,0,0,0); + --color-select-menu-tap-highlight: rgba(175,184,193,0.5); + --color-select-menu-tap-focus-bg: #b6e3ff; + --color-overlay-shadow: 0 1px 3px rgba(27,31,36,0.12), 0 8px 24px rgba(66,74,83,0.12); + --color-header-text: rgba(255,255,255,0.7); + --color-header-bg: #24292f; + --color-header-logo: #ffffff; + --color-header-search-bg: #24292f; + --color-header-search-border: #57606a; + --color-sidenav-selected-bg: #ffffff; + --color-menu-bg-active: rgba(0,0,0,0); + --color-control-transparent-bg-hover: #818b981a; + --color-input-disabled-bg: rgba(175,184,193,0.2); + --color-timeline-badge-bg: #eaeef2; + --color-ansi-black: #24292f; + --color-ansi-black-bright: #57606a; + --color-ansi-white: #6e7781; + --color-ansi-white-bright: #8c959f; + --color-ansi-gray: #6e7781; + --color-ansi-red: #cf222e; + --color-ansi-red-bright: #a40e26; + --color-ansi-green: #116329; + --color-ansi-green-bright: #1a7f37; + --color-ansi-yellow: #4d2d00; + --color-ansi-yellow-bright: #633c01; + --color-ansi-blue: #0969da; + --color-ansi-blue-bright: #218bff; + --color-ansi-magenta: #8250df; + --color-ansi-magenta-bright: #a475f9; + --color-ansi-cyan: #1b7c83; + --color-ansi-cyan-bright: #3192aa; + --color-btn-text: #24292f; + --color-btn-bg: #f6f8fa; + --color-btn-border: rgba(27,31,36,0.15); + --color-btn-shadow: 0 1px 0 rgba(27,31,36,0.04); + --color-btn-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.25); + --color-btn-hover-bg: #f3f4f6; + --color-btn-hover-border: rgba(27,31,36,0.15); + --color-btn-active-bg: hsla(220,14%,93%,1); + --color-btn-active-border: rgba(27,31,36,0.15); + --color-btn-selected-bg: hsla(220,14%,94%,1); + --color-btn-focus-bg: #f6f8fa; + --color-btn-focus-border: rgba(27,31,36,0.15); + --color-btn-focus-shadow: 0 0 0 3px rgba(9,105,218,0.3); + --color-btn-shadow-active: inset 0 0.15em 0.3em rgba(27,31,36,0.15); + --color-btn-shadow-input-focus: 0 0 0 0.2em rgba(9,105,218,0.3); + --color-btn-counter-bg: rgba(27,31,36,0.08); + --color-btn-primary-text: #ffffff; + --color-btn-primary-bg: #2da44e; + --color-btn-primary-border: rgba(27,31,36,0.15); + --color-btn-primary-shadow: 0 1px 0 rgba(27,31,36,0.1); + --color-btn-primary-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + --color-btn-primary-hover-bg: #2c974b; + --color-btn-primary-hover-border: rgba(27,31,36,0.15); + --color-btn-primary-selected-bg: hsla(137,55%,36%,1); + --color-btn-primary-selected-shadow: inset 0 1px 0 rgba(0,45,17,0.2); + --color-btn-primary-disabled-text: rgba(255,255,255,0.8); + --color-btn-primary-disabled-bg: #94d3a2; + --color-btn-primary-disabled-border: rgba(27,31,36,0.15); + --color-btn-primary-focus-bg: #2da44e; + --color-btn-primary-focus-border: rgba(27,31,36,0.15); + --color-btn-primary-focus-shadow: 0 0 0 3px rgba(45,164,78,0.4); + --color-btn-primary-icon: rgba(255,255,255,0.8); + --color-btn-primary-counter-bg: rgba(255,255,255,0.2); + --color-btn-outline-text: #0969da; + --color-btn-outline-hover-text: #ffffff; + --color-btn-outline-hover-bg: #0969da; + --color-btn-outline-hover-border: rgba(27,31,36,0.15); + --color-btn-outline-hover-shadow: 0 1px 0 rgba(27,31,36,0.1); + --color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + --color-btn-outline-hover-counter-bg: rgba(255,255,255,0.2); + --color-btn-outline-selected-text: #ffffff; + --color-btn-outline-selected-bg: hsla(212,92%,42%,1); + --color-btn-outline-selected-border: rgba(27,31,36,0.15); + --color-btn-outline-selected-shadow: inset 0 1px 0 rgba(0,33,85,0.2); + --color-btn-outline-disabled-text: rgba(9,105,218,0.5); + --color-btn-outline-disabled-bg: #f6f8fa; + --color-btn-outline-disabled-counter-bg: rgba(9,105,218,0.05); + --color-btn-outline-focus-border: rgba(27,31,36,0.15); + --color-btn-outline-focus-shadow: 0 0 0 3px rgba(5,80,174,0.4); + --color-btn-outline-counter-bg: rgba(9,105,218,0.1); + --color-btn-danger-text: #cf222e; + --color-btn-danger-hover-text: #ffffff; + --color-btn-danger-hover-bg: #a40e26; + --color-btn-danger-hover-border: rgba(27,31,36,0.15); + --color-btn-danger-hover-shadow: 0 1px 0 rgba(27,31,36,0.1); + --color-btn-danger-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03); + --color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2); + --color-btn-danger-selected-text: #ffffff; + --color-btn-danger-selected-bg: hsla(356,72%,44%,1); + --color-btn-danger-selected-border: rgba(27,31,36,0.15); + --color-btn-danger-selected-shadow: inset 0 1px 0 rgba(76,0,20,0.2); + --color-btn-danger-disabled-text: rgba(207,34,46,0.5); + --color-btn-danger-disabled-bg: #f6f8fa; + --color-btn-danger-disabled-counter-bg: rgba(207,34,46,0.05); + --color-btn-danger-focus-border: rgba(27,31,36,0.15); + --color-btn-danger-focus-shadow: 0 0 0 3px rgba(164,14,38,0.4); + --color-btn-danger-counter-bg: rgba(207,34,46,0.1); + --color-btn-danger-icon: #cf222e; + --color-btn-danger-hover-icon: #ffffff; + --color-underlinenav-icon: #6e7781; + --color-underlinenav-border-hover: rgba(175,184,193,0.2); + --color-fg-default: #24292f; + --color-fg-muted: #57606a; + --color-fg-subtle: #6e7781; + --color-fg-on-emphasis: #ffffff; + --color-canvas-default: #ffffff; + --color-canvas-overlay: #ffffff; + --color-canvas-inset: #f6f8fa; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210,18%,87%,1); + --color-border-subtle: rgba(27,31,36,0.15); + --color-shadow-small: 0 1px 0 rgba(27,31,36,0.04); + --color-shadow-medium: 0 3px 6px rgba(140,149,159,0.15); + --color-shadow-large: 0 8px 24px rgba(140,149,159,0.2); + --color-shadow-extra-large: 0 12px 28px rgba(140,149,159,0.3); + --color-neutral-emphasis-plus: #24292f; + --color-neutral-emphasis: #6e7781; + --color-neutral-muted: rgba(175,184,193,0.2); + --color-neutral-subtle: rgba(234,238,242,0.5); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-accent-muted: rgba(84,174,255,0.4); + --color-accent-subtle: #ddf4ff; + --color-success-fg: #1a7f37; + --color-success-emphasis: #2da44e; + --color-success-muted: rgba(74,194,107,0.4); + --color-success-subtle: #dafbe1; + --color-attention-fg: #9a6700; + --color-attention-emphasis: #bf8700; + --color-attention-muted: rgba(212,167,44,0.4); + --color-attention-subtle: #fff8c5; + --color-severe-fg: #bc4c00; + --color-severe-emphasis: #bc4c00; + --color-severe-muted: rgba(251,143,68,0.4); + --color-severe-subtle: #fff1e5; + --color-danger-fg: #cf222e; + --color-danger-emphasis: #cf222e; + --color-danger-muted: rgba(255,129,130,0.4); + --color-danger-subtle: #FFEBE9; + --color-done-fg: #8250df; + --color-done-emphasis: #8250df; + --color-done-muted: rgba(194,151,255,0.4); + --color-done-subtle: #fbefff; + --color-sponsors-fg: #bf3989; + --color-sponsors-emphasis: #bf3989; + --color-sponsors-muted: rgba(255,128,200,0.4); + --color-sponsors-subtle: #ffeff7; + --color-primer-canvas-backdrop: rgba(27,31,36,0.5); + --color-primer-canvas-sticky: rgba(255,255,255,0.95); + --color-primer-border-active: #FD8C73; + --color-primer-border-contrast: rgba(27,31,36,0.1); + --color-primer-shadow-highlight: inset 0 1px 0 rgba(255,255,255,0.25); + --color-primer-shadow-inset: inset 0 1px 0 rgba(208,215,222,0.2); + --color-primer-shadow-focus: 0 0 0 3px rgba(9,105,218,0.3); + --color-scale-black: #1b1f24; + --color-scale-white: #ffffff; + --color-scale-gray-0: #f6f8fa; + --color-scale-gray-1: #eaeef2; + --color-scale-gray-2: #d0d7de; + --color-scale-gray-3: #afb8c1; + --color-scale-gray-4: #8c959f; + --color-scale-gray-5: #6e7781; + --color-scale-gray-6: #57606a; + --color-scale-gray-7: #424a53; + --color-scale-gray-8: #32383f; + --color-scale-gray-9: #24292f; + --color-scale-blue-0: #ddf4ff; + --color-scale-blue-1: #b6e3ff; + --color-scale-blue-2: #80ccff; + --color-scale-blue-3: #54aeff; + --color-scale-blue-4: #218bff; + --color-scale-blue-5: #0969da; + --color-scale-blue-6: #0550ae; + --color-scale-blue-7: #033d8b; + --color-scale-blue-8: #0a3069; + --color-scale-blue-9: #002155; + --color-scale-green-0: #dafbe1; + --color-scale-green-1: #aceebb; + --color-scale-green-2: #6fdd8b; + --color-scale-green-3: #4ac26b; + --color-scale-green-4: #2da44e; + --color-scale-green-5: #1a7f37; + --color-scale-green-6: #116329; + --color-scale-green-7: #044f1e; + --color-scale-green-8: #003d16; + --color-scale-green-9: #002d11; + --color-scale-yellow-0: #fff8c5; + --color-scale-yellow-1: #fae17d; + --color-scale-yellow-2: #eac54f; + --color-scale-yellow-3: #d4a72c; + --color-scale-yellow-4: #bf8700; + --color-scale-yellow-5: #9a6700; + --color-scale-yellow-6: #7d4e00; + --color-scale-yellow-7: #633c01; + --color-scale-yellow-8: #4d2d00; + --color-scale-yellow-9: #3b2300; + --color-scale-orange-0: #fff1e5; + --color-scale-orange-1: #ffd8b5; + --color-scale-orange-2: #ffb77c; + --color-scale-orange-3: #fb8f44; + --color-scale-orange-4: #e16f24; + --color-scale-orange-5: #bc4c00; + --color-scale-orange-6: #953800; + --color-scale-orange-7: #762c00; + --color-scale-orange-8: #5c2200; + --color-scale-orange-9: #471700; + --color-scale-red-0: #FFEBE9; + --color-scale-red-1: #ffcecb; + --color-scale-red-2: #ffaba8; + --color-scale-red-3: #ff8182; + --color-scale-red-4: #fa4549; + --color-scale-red-5: #cf222e; + --color-scale-red-6: #a40e26; + --color-scale-red-7: #82071e; + --color-scale-red-8: #660018; + --color-scale-red-9: #4c0014; + --color-scale-purple-0: #fbefff; + --color-scale-purple-1: #ecd8ff; + --color-scale-purple-2: #d8b9ff; + --color-scale-purple-3: #c297ff; + --color-scale-purple-4: #a475f9; + --color-scale-purple-5: #8250df; + --color-scale-purple-6: #6639ba; + --color-scale-purple-7: #512a97; + --color-scale-purple-8: #3e1f79; + --color-scale-purple-9: #2e1461; + --color-scale-pink-0: #ffeff7; + --color-scale-pink-1: #ffd3eb; + --color-scale-pink-2: #ffadda; + --color-scale-pink-3: #ff80c8; + --color-scale-pink-4: #e85aad; + --color-scale-pink-5: #bf3989; + --color-scale-pink-6: #99286e; + --color-scale-pink-7: #772057; + --color-scale-pink-8: #611347; + --color-scale-pink-9: #4d0336; + --color-scale-coral-0: #FFF0EB; + --color-scale-coral-1: #FFD6CC; + --color-scale-coral-2: #FFB4A1; + --color-scale-coral-3: #FD8C73; + --color-scale-coral-4: #EC6547; + --color-scale-coral-5: #C4432B; + --color-scale-coral-6: #9E2F1C; + --color-scale-coral-7: #801F0F; + --color-scale-coral-8: #691105; + --color-scale-coral-9: #510901 +} + +@media(prefers-color-scheme: dark) { + :root { + --color-canvas-default-transparent: rgba(13,17,23,0); + --color-marketing-icon-primary: #79c0ff; + --color-marketing-icon-secondary: #1f6feb; + --color-diff-blob-addition-num-text: #c9d1d9; + --color-diff-blob-addition-fg: #c9d1d9; + --color-diff-blob-addition-num-bg: rgba(63,185,80,0.3); + --color-diff-blob-addition-line-bg: rgba(46,160,67,0.15); + --color-diff-blob-addition-word-bg: rgba(46,160,67,0.4); + --color-diff-blob-deletion-num-text: #c9d1d9; + --color-diff-blob-deletion-fg: #c9d1d9; + --color-diff-blob-deletion-num-bg: rgba(248,81,73,0.3); + --color-diff-blob-deletion-line-bg: rgba(248,81,73,0.15); + --color-diff-blob-deletion-word-bg: rgba(248,81,73,0.4); + --color-diff-blob-hunk-num-bg: rgba(56,139,253,0.4); + --color-diff-blob-expander-icon: #8b949e; + --color-diff-blob-selected-line-highlight-mix-blend-mode: screen; + --color-diffstat-deletion-border: rgba(240,246,252,0.1); + --color-diffstat-addition-border: rgba(240,246,252,0.1); + --color-diffstat-addition-bg: #3fb950; + --color-search-keyword-hl: rgba(210,153,34,0.4); + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-codemirror-text: #c9d1d9; + --color-codemirror-bg: #0d1117; + --color-codemirror-gutters-bg: #0d1117; + --color-codemirror-guttermarker-text: #0d1117; + --color-codemirror-guttermarker-subtle-text: #484f58; + --color-codemirror-linenumber-text: #8b949e; + --color-codemirror-cursor: #c9d1d9; + --color-codemirror-selection-bg: rgba(56,139,253,0.4); + --color-codemirror-activeline-bg: rgba(110,118,129,0.1); + --color-codemirror-matchingbracket-text: #c9d1d9; + --color-codemirror-lines-bg: #0d1117; + --color-codemirror-syntax-comment: #8b949e; + --color-codemirror-syntax-constant: #79c0ff; + --color-codemirror-syntax-entity: #d2a8ff; + --color-codemirror-syntax-keyword: #ff7b72; + --color-codemirror-syntax-storage: #ff7b72; + --color-codemirror-syntax-string: #a5d6ff; + --color-codemirror-syntax-support: #79c0ff; + --color-codemirror-syntax-variable: #ffa657; + --color-checks-bg: #010409; + --color-checks-run-border-width: 1px; + --color-checks-container-border-width: 1px; + --color-checks-text-primary: #c9d1d9; + --color-checks-text-secondary: #8b949e; + --color-checks-text-link: #58a6ff; + --color-checks-btn-icon: #8b949e; + --color-checks-btn-hover-icon: #c9d1d9; + --color-checks-btn-hover-bg: rgba(110,118,129,0.1); + --color-checks-input-text: #8b949e; + --color-checks-input-placeholder-text: #484f58; + --color-checks-input-focus-text: #c9d1d9; + --color-checks-input-bg: #161b22; + --color-checks-input-shadow: none; + --color-checks-donut-error: #f85149; + --color-checks-donut-pending: #d29922; + --color-checks-donut-success: #2ea043; + --color-checks-donut-neutral: #8b949e; + --color-checks-dropdown-text: #c9d1d9; + --color-checks-dropdown-bg: #161b22; + --color-checks-dropdown-border: #30363d; + --color-checks-dropdown-shadow: rgba(1,4,9,0.3); + --color-checks-dropdown-hover-text: #c9d1d9; + --color-checks-dropdown-hover-bg: rgba(110,118,129,0.1); + --color-checks-dropdown-btn-hover-text: #c9d1d9; + --color-checks-dropdown-btn-hover-bg: rgba(110,118,129,0.1); + --color-checks-scrollbar-thumb-bg: rgba(110,118,129,0.4); + --color-checks-header-label-text: #8b949e; + --color-checks-header-label-open-text: #c9d1d9; + --color-checks-header-border: #21262d; + --color-checks-header-icon: #8b949e; + --color-checks-line-text: #8b949e; + --color-checks-line-num-text: #484f58; + --color-checks-line-timestamp-text: #484f58; + --color-checks-line-hover-bg: rgba(110,118,129,0.1); + --color-checks-line-selected-bg: rgba(56,139,253,0.15); + --color-checks-line-selected-num-text: #58a6ff; + --color-checks-line-dt-fm-text: #f0f6fc; + --color-checks-line-dt-fm-bg: #9e6a03; + --color-checks-gate-bg: rgba(187,128,9,0.15); + --color-checks-gate-text: #8b949e; + --color-checks-gate-waiting-text: #d29922; + --color-checks-step-header-open-bg: #161b22; + --color-checks-step-error-text: #f85149; + --color-checks-step-warning-text: #d29922; + --color-checks-logline-text: #8b949e; + --color-checks-logline-num-text: #484f58; + --color-checks-logline-debug-text: #a371f7; + --color-checks-logline-error-text: #8b949e; + --color-checks-logline-error-num-text: #484f58; + --color-checks-logline-error-bg: rgba(248,81,73,0.15); + --color-checks-logline-warning-text: #8b949e; + --color-checks-logline-warning-num-text: #d29922; + --color-checks-logline-warning-bg: rgba(187,128,9,0.15); + --color-checks-logline-command-text: #58a6ff; + --color-checks-logline-section-text: #3fb950; + --color-checks-ansi-black: #0d1117; + --color-checks-ansi-black-bright: #161b22; + --color-checks-ansi-white: #b1bac4; + --color-checks-ansi-white-bright: #b1bac4; + --color-checks-ansi-gray: #6e7681; + --color-checks-ansi-red: #ff7b72; + --color-checks-ansi-red-bright: #ffa198; + --color-checks-ansi-green: #3fb950; + --color-checks-ansi-green-bright: #56d364; + --color-checks-ansi-yellow: #d29922; + --color-checks-ansi-yellow-bright: #e3b341; + --color-checks-ansi-blue: #58a6ff; + --color-checks-ansi-blue-bright: #79c0ff; + --color-checks-ansi-magenta: #bc8cff; + --color-checks-ansi-magenta-bright: #d2a8ff; + --color-checks-ansi-cyan: #76e3ea; + --color-checks-ansi-cyan-bright: #b3f0ff; + --color-project-header-bg: #0d1117; + --color-project-sidebar-bg: #161b22; + --color-project-gradient-in: #161b22; + --color-project-gradient-out: rgba(22,27,34,0); + --color-mktg-success: rgba(41,147,61,1); + --color-mktg-info: rgba(42,123,243,1); + --color-mktg-bg-shade-gradient-top: rgba(1,4,9,0.065); + --color-mktg-bg-shade-gradient-bottom: rgba(1,4,9,0); + --color-mktg-btn-bg-top: hsla(228,82%,66%,1); + --color-mktg-btn-bg-bottom: #4969ed; + --color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1); + --color-mktg-btn-bg-overlay-bottom: #3355e0; + --color-mktg-btn-text: #f0f6fc; + --color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1); + --color-mktg-btn-primary-bg-bottom: #2ea44f; + --color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1); + --color-mktg-btn-primary-bg-overlay-bottom: #22863a; + --color-mktg-btn-primary-text: #f0f6fc; + --color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1); + --color-mktg-btn-enterprise-bg-bottom: #6f57ff; + --color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1); + --color-mktg-btn-enterprise-bg-overlay-bottom: #614eda; + --color-mktg-btn-enterprise-text: #f0f6fc; + --color-mktg-btn-outline-text: #f0f6fc; + --color-mktg-btn-outline-border: rgba(240,246,252,0.3); + --color-mktg-btn-outline-hover-text: #f0f6fc; + --color-mktg-btn-outline-hover-border: rgba(240,246,252,0.5); + --color-mktg-btn-outline-focus-border: #f0f6fc; + --color-mktg-btn-outline-focus-border-inset: rgba(240,246,252,0.5); + --color-mktg-btn-dark-text: #f0f6fc; + --color-mktg-btn-dark-border: rgba(240,246,252,0.3); + --color-mktg-btn-dark-hover-text: #f0f6fc; + --color-mktg-btn-dark-hover-border: rgba(240,246,252,0.5); + --color-mktg-btn-dark-focus-border: #f0f6fc; + --color-mktg-btn-dark-focus-border-inset: rgba(240,246,252,0.5); + --color-avatar-bg: rgba(240,246,252,0.1); + --color-avatar-border: rgba(240,246,252,0.1); + --color-avatar-stack-fade: #30363d; + --color-avatar-stack-fade-more: #21262d; + --color-avatar-child-shadow: -2px -2px 0 #0d1117; + --color-topic-tag-border: rgba(0,0,0,0); + --color-select-menu-backdrop-border: #484f58; + --color-select-menu-tap-highlight: rgba(48,54,61,0.5); + --color-select-menu-tap-focus-bg: #0c2d6b; + --color-overlay-shadow: 0 0 0 1px #30363d, 0 16px 32px rgba(1,4,9,0.85); + --color-header-text: rgba(240,246,252,0.7); + --color-header-bg: #161b22; + --color-header-logo: #f0f6fc; + --color-header-search-bg: #0d1117; + --color-header-search-border: #30363d; + --color-sidenav-selected-bg: #21262d; + --color-menu-bg-active: #161b22; + --color-control-transparent-bg-hover: #656c7633; + --color-input-disabled-bg: rgba(110,118,129,0); + --color-timeline-badge-bg: #21262d; + --color-ansi-black: #484f58; + --color-ansi-black-bright: #6e7681; + --color-ansi-white: #b1bac4; + --color-ansi-white-bright: #f0f6fc; + --color-ansi-gray: #6e7681; + --color-ansi-red: #ff7b72; + --color-ansi-red-bright: #ffa198; + --color-ansi-green: #3fb950; + --color-ansi-green-bright: #56d364; + --color-ansi-yellow: #d29922; + --color-ansi-yellow-bright: #e3b341; + --color-ansi-blue: #58a6ff; + --color-ansi-blue-bright: #79c0ff; + --color-ansi-magenta: #bc8cff; + --color-ansi-magenta-bright: #d2a8ff; + --color-ansi-cyan: #39c5cf; + --color-ansi-cyan-bright: #56d4dd; + --color-btn-text: #c9d1d9; + --color-btn-bg: #21262d; + --color-btn-border: rgba(240,246,252,0.1); + --color-btn-shadow: 0 0 transparent; + --color-btn-inset-shadow: 0 0 transparent; + --color-btn-hover-bg: #30363d; + --color-btn-hover-border: #8b949e; + --color-btn-active-bg: hsla(212,12%,18%,1); + --color-btn-active-border: #6e7681; + --color-btn-selected-bg: #161b22; + --color-btn-focus-bg: #21262d; + --color-btn-focus-border: #8b949e; + --color-btn-focus-shadow: 0 0 0 3px rgba(139,148,158,0.3); + --color-btn-shadow-active: inset 0 0.15em 0.3em rgba(1,4,9,0.15); + --color-btn-shadow-input-focus: 0 0 0 0.2em rgba(31,111,235,0.3); + --color-btn-counter-bg: #30363d; + --color-btn-primary-text: #ffffff; + --color-btn-primary-bg: #238636; + --color-btn-primary-border: rgba(240,246,252,0.1); + --color-btn-primary-shadow: 0 0 transparent; + --color-btn-primary-inset-shadow: 0 0 transparent; + --color-btn-primary-hover-bg: #2ea043; + --color-btn-primary-hover-border: rgba(240,246,252,0.1); + --color-btn-primary-selected-bg: #238636; + --color-btn-primary-selected-shadow: 0 0 transparent; + --color-btn-primary-disabled-text: rgba(240,246,252,0.5); + --color-btn-primary-disabled-bg: rgba(35,134,54,0.6); + --color-btn-primary-disabled-border: rgba(240,246,252,0.1); + --color-btn-primary-focus-bg: #238636; + --color-btn-primary-focus-border: rgba(240,246,252,0.1); + --color-btn-primary-focus-shadow: 0 0 0 3px rgba(46,164,79,0.4); + --color-btn-primary-icon: #f0f6fc; + --color-btn-primary-counter-bg: rgba(240,246,252,0.2); + --color-btn-outline-text: #58a6ff; + --color-btn-outline-hover-text: #58a6ff; + --color-btn-outline-hover-bg: #30363d; + --color-btn-outline-hover-border: rgba(240,246,252,0.1); + --color-btn-outline-hover-shadow: 0 1px 0 rgba(1,4,9,0.1); + --color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(240,246,252,0.03); + --color-btn-outline-hover-counter-bg: rgba(240,246,252,0.2); + --color-btn-outline-selected-text: #f0f6fc; + --color-btn-outline-selected-bg: #0d419d; + --color-btn-outline-selected-border: rgba(240,246,252,0.1); + --color-btn-outline-selected-shadow: 0 0 transparent; + --color-btn-outline-disabled-text: rgba(88,166,255,0.5); + --color-btn-outline-disabled-bg: #0d1117; + --color-btn-outline-disabled-counter-bg: rgba(31,111,235,0.05); + --color-btn-outline-focus-border: rgba(240,246,252,0.1); + --color-btn-outline-focus-shadow: 0 0 0 3px rgba(17,88,199,0.4); + --color-btn-outline-counter-bg: rgba(31,111,235,0.1); + --color-btn-danger-text: #f85149; + --color-btn-danger-hover-text: #f0f6fc; + --color-btn-danger-hover-bg: #da3633; + --color-btn-danger-hover-border: #f85149; + --color-btn-danger-hover-shadow: 0 0 transparent; + --color-btn-danger-hover-inset-shadow: 0 0 transparent; + --color-btn-danger-hover-icon: #f0f6fc; + --color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2); + --color-btn-danger-selected-text: #ffffff; + --color-btn-danger-selected-bg: #b62324; + --color-btn-danger-selected-border: #ff7b72; + --color-btn-danger-selected-shadow: 0 0 transparent; + --color-btn-danger-disabled-text: rgba(248,81,73,0.5); + --color-btn-danger-disabled-bg: #0d1117; + --color-btn-danger-disabled-counter-bg: rgba(218,54,51,0.05); + --color-btn-danger-focus-border: #f85149; + --color-btn-danger-focus-shadow: 0 0 0 3px rgba(248,81,73,0.4); + --color-btn-danger-counter-bg: rgba(218,54,51,0.1); + --color-btn-danger-icon: #f85149; + --color-underlinenav-icon: #484f58; + --color-underlinenav-border-hover: rgba(110,118,129,0.4); + --color-fg-default: #c9d1d9; + --color-fg-muted: #8b949e; + --color-fg-subtle: #484f58; + --color-fg-on-emphasis: #f0f6fc; + --color-canvas-default: #0d1117; + --color-canvas-overlay: #161b22; + --color-canvas-inset: #010409; + --color-canvas-subtle: #161b22; + --color-border-default: #30363d; + --color-border-muted: #21262d; + --color-border-subtle: rgba(240,246,252,0.1); + --color-shadow-small: 0 0 transparent; + --color-shadow-medium: 0 3px 6px #010409; + --color-shadow-large: 0 8px 24px #010409; + --color-shadow-extra-large: 0 12px 48px #010409; + --color-neutral-emphasis-plus: #6e7681; + --color-neutral-emphasis: #6e7681; + --color-neutral-muted: rgba(110,118,129,0.4); + --color-neutral-subtle: rgba(110,118,129,0.1); + --color-accent-fg: #58a6ff; + --color-accent-emphasis: #1f6feb; + --color-accent-muted: rgba(56,139,253,0.4); + --color-accent-subtle: rgba(56,139,253,0.15); + --color-success-fg: #3fb950; + --color-success-emphasis: #238636; + --color-success-muted: rgba(46,160,67,0.4); + --color-success-subtle: rgba(46,160,67,0.15); + --color-attention-fg: #d29922; + --color-attention-emphasis: #9e6a03; + --color-attention-muted: rgba(187,128,9,0.4); + --color-attention-subtle: rgba(187,128,9,0.15); + --color-severe-fg: #db6d28; + --color-severe-emphasis: #bd561d; + --color-severe-muted: rgba(219,109,40,0.4); + --color-severe-subtle: rgba(219,109,40,0.15); + --color-danger-fg: #f85149; + --color-danger-emphasis: #da3633; + --color-danger-muted: rgba(248,81,73,0.4); + --color-danger-subtle: rgba(248,81,73,0.15); + --color-done-fg: #a371f7; + --color-done-emphasis: #8957e5; + --color-done-muted: rgba(163,113,247,0.4); + --color-done-subtle: rgba(163,113,247,0.15); + --color-sponsors-fg: #db61a2; + --color-sponsors-emphasis: #bf4b8a; + --color-sponsors-muted: rgba(219,97,162,0.4); + --color-sponsors-subtle: rgba(219,97,162,0.15); + --color-primer-canvas-backdrop: rgba(1,4,9,0.8); + --color-primer-canvas-sticky: rgba(13,17,23,0.95); + --color-primer-border-active: #F78166; + --color-primer-border-contrast: rgba(240,246,252,0.2); + --color-primer-shadow-highlight: 0 0 transparent; + --color-primer-shadow-inset: 0 0 transparent; + --color-primer-shadow-focus: 0 0 0 3px #0c2d6b; + --color-scale-black: #010409; + --color-scale-white: #f0f6fc; + --color-scale-gray-0: #f0f6fc; + --color-scale-gray-1: #c9d1d9; + --color-scale-gray-2: #b1bac4; + --color-scale-gray-3: #8b949e; + --color-scale-gray-4: #6e7681; + --color-scale-gray-5: #484f58; + --color-scale-gray-6: #30363d; + --color-scale-gray-7: #21262d; + --color-scale-gray-8: #161b22; + --color-scale-gray-9: #0d1117; + --color-scale-blue-0: #cae8ff; + --color-scale-blue-1: #a5d6ff; + --color-scale-blue-2: #79c0ff; + --color-scale-blue-3: #58a6ff; + --color-scale-blue-4: #388bfd; + --color-scale-blue-5: #1f6feb; + --color-scale-blue-6: #1158c7; + --color-scale-blue-7: #0d419d; + --color-scale-blue-8: #0c2d6b; + --color-scale-blue-9: #051d4d; + --color-scale-green-0: #aff5b4; + --color-scale-green-1: #7ee787; + --color-scale-green-2: #56d364; + --color-scale-green-3: #3fb950; + --color-scale-green-4: #2ea043; + --color-scale-green-5: #238636; + --color-scale-green-6: #196c2e; + --color-scale-green-7: #0f5323; + --color-scale-green-8: #033a16; + --color-scale-green-9: #04260f; + --color-scale-yellow-0: #f8e3a1; + --color-scale-yellow-1: #f2cc60; + --color-scale-yellow-2: #e3b341; + --color-scale-yellow-3: #d29922; + --color-scale-yellow-4: #bb8009; + --color-scale-yellow-5: #9e6a03; + --color-scale-yellow-6: #845306; + --color-scale-yellow-7: #693e00; + --color-scale-yellow-8: #4b2900; + --color-scale-yellow-9: #341a00; + --color-scale-orange-0: #ffdfb6; + --color-scale-orange-1: #ffc680; + --color-scale-orange-2: #ffa657; + --color-scale-orange-3: #f0883e; + --color-scale-orange-4: #db6d28; + --color-scale-orange-5: #bd561d; + --color-scale-orange-6: #9b4215; + --color-scale-orange-7: #762d0a; + --color-scale-orange-8: #5a1e02; + --color-scale-orange-9: #3d1300; + --color-scale-red-0: #ffdcd7; + --color-scale-red-1: #ffc1ba; + --color-scale-red-2: #ffa198; + --color-scale-red-3: #ff7b72; + --color-scale-red-4: #f85149; + --color-scale-red-5: #da3633; + --color-scale-red-6: #b62324; + --color-scale-red-7: #8e1519; + --color-scale-red-8: #67060c; + --color-scale-red-9: #490202; + --color-scale-purple-0: #eddeff; + --color-scale-purple-1: #e2c5ff; + --color-scale-purple-2: #d2a8ff; + --color-scale-purple-3: #bc8cff; + --color-scale-purple-4: #a371f7; + --color-scale-purple-5: #8957e5; + --color-scale-purple-6: #6e40c9; + --color-scale-purple-7: #553098; + --color-scale-purple-8: #3c1e70; + --color-scale-purple-9: #271052; + --color-scale-pink-0: #ffdaec; + --color-scale-pink-1: #ffbedd; + --color-scale-pink-2: #ff9bce; + --color-scale-pink-3: #f778ba; + --color-scale-pink-4: #db61a2; + --color-scale-pink-5: #bf4b8a; + --color-scale-pink-6: #9e3670; + --color-scale-pink-7: #7d2457; + --color-scale-pink-8: #5e103e; + --color-scale-pink-9: #42062a; + --color-scale-coral-0: #FFDDD2; + --color-scale-coral-1: #FFC2B2; + --color-scale-coral-2: #FFA28B; + --color-scale-coral-3: #F78166; + --color-scale-coral-4: #EA6045; + --color-scale-coral-5: #CF462D; + --color-scale-coral-6: #AC3220; + --color-scale-coral-7: #872012; + --color-scale-coral-8: #640D04; + --color-scale-coral-9: #460701 + } +} diff --git a/extension-playwright/src/ui/connect.css b/extension-playwright/src/ui/connect.css new file mode 100644 index 0000000..fe2588f --- /dev/null +++ b/extension-playwright/src/ui/connect.css @@ -0,0 +1,262 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +body { + margin: 0; + padding: 0; +} + +/* Base styles */ +.app-container { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + background-color: #ffffff; + color: #1f2328; + margin: 0; + padding: 16px; + min-height: 100vh; + font-size: 14px; +} + +.content-wrapper { + max-width: 600px; + margin: 0 auto; +} + +/* Status Banner */ +.status-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-right: 12px; +} + +.status-banner { + padding: 12px; + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.status-banner.connected { + color: #1f2328; +} + +.status-banner.connected::before { + content: "\2705"; + margin-right: 8px; +} + +.status-banner.error { + color: #1f2328; +} + +.status-banner.error::before { + content: "\274C"; + margin-right: 8px; +} + +/* Buttons */ +.button-container { + margin-bottom: 16px; + display: flex; + justify-content: flex-end; + padding-right: 12px; +} + +.button { + padding: 8px 16px; + border-radius: 6px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + margin-right: 8px; + min-width: 90px; +} + +.button.primary { + background-color: #f8f9fa; + color: #3c4043; + border: 1px solid #dadce0; +} + +.button.primary:hover { + background-color: #f1f3f4; + border-color: #dadce0; + box-shadow: 0 1px 2px 0 rgba(60,64,67,.1); +} + +.button.default { + background-color: #f6f8fa; + color: #24292f; +} + +.button.default:hover { + background-color: #f3f4f6; +} + +.button.reject { + background-color: #da3633; + color: #ffffff; + border: 1px solid #da3633; +} + +.button.reject:hover { + background-color: #c73836; + border-color: #c73836; +} + +/* Tab selection */ +.tab-section-title { + padding-left: 12px; + font-size: 12px; + font-weight: 400; + margin-bottom: 12px; + color: #656d76; +} + +.tab-item { + display: flex; + align-items: center; + padding: 12px; + margin-bottom: 8px; + background-color: #ffffff; + cursor: pointer; + border-radius: 6px; + transition: background-color 0.2s ease; +} + +.tab-item:hover { + background-color: #f8f9fa; +} + +.tab-item.selected { + background-color: #f6f8fa; +} + +.tab-item.disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.tab-radio { + margin-right: 12px; + flex-shrink: 0; +} + +.tab-favicon { + width: 16px; + height: 16px; + margin-right: 8px; + flex-shrink: 0; +} + +.tab-content { + flex: 1; + min-width: 0; +} + +.tab-title { + font-weight: 500; + color: #1f2328; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-url { + font-size: 12px; + color: #656d76; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Link-style button */ +.link-button { + background: none; + border: none; + color: #0066cc; + text-decoration: underline; + cursor: pointer; + padding: 0; + font: inherit; +} + +/* Auth token section */ +.auth-token-section { + margin: 16px 0; + padding: 16px; + background-color: #f6f8fa; + border-radius: 6px; +} + +.auth-token-description { + font-size: 12px; + color: #656d76; + margin-bottom: 12px; +} + +.auth-token-container { + display: flex; + align-items: center; + gap: 8px; + background-color: #ffffff; + padding: 8px; +} + +.auth-token-code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + color: #1f2328; + border: none; + flex: 1; + padding: 0; + word-break: break-all; +} + +.auth-token-refresh { + flex: none; + height: 24px; + width: 24px; + border: none; + outline: none; + color: var(--color-fg-muted); + background: transparent; + padding: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.auth-token-refresh svg { + margin: 0; +} + +.auth-token-refresh:not(:disabled):hover { + background-color: var(--color-btn-selected-bg); +} diff --git a/extension-playwright/src/ui/connect.html b/extension-playwright/src/ui/connect.html new file mode 100644 index 0000000..3f20e4b --- /dev/null +++ b/extension-playwright/src/ui/connect.html @@ -0,0 +1,29 @@ + + + + + Playwright MCP extension + + + + + + +
+ + + \ No newline at end of file diff --git a/extension-playwright/src/ui/connect.tsx b/extension-playwright/src/ui/connect.tsx new file mode 100644 index 0000000..e9e410e --- /dev/null +++ b/extension-playwright/src/ui/connect.tsx @@ -0,0 +1,253 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Button, TabItem } from './tabItem'; +import { AuthTokenSection, getOrCreateAuthToken } from './authToken'; + +import type { TabInfo } from './tabItem'; + +type Status = + | { type: 'connecting'; message: string } + | { type: 'connected'; message: string } + | { type: 'error'; message: string } + | { type: 'error'; versionMismatch: { extensionVersion: string; } }; + +const SUPPORTED_PROTOCOL_VERSION = 1; + +const ConnectApp: React.FC = () => { + const [tabs, setTabs] = useState([]); + const [status, setStatus] = useState(null); + const [showButtons, setShowButtons] = useState(true); + const [showTabList, setShowTabList] = useState(true); + const [clientInfo, setClientInfo] = useState('unknown'); + const [mcpRelayUrl, setMcpRelayUrl] = useState(''); + const [newTab, setNewTab] = useState(false); + + useEffect(() => { + const runAsync = async () => { + const params = new URLSearchParams(window.location.search); + const relayUrl = params.get('mcpRelayUrl'); + + if (!relayUrl) { + setShowButtons(false); + setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' }); + return; + } + + setMcpRelayUrl(relayUrl); + + try { + const client = JSON.parse(params.get('client') || '{}'); + const info = `${client.name}/${client.version}`; + setClientInfo(info); + setStatus({ + type: 'connecting', + message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?` + }); + } catch (e) { + setStatus({ type: 'error', message: 'Failed to parse client version.' }); + return; + } + + const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10); + const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion; + if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) { + const extensionVersion = chrome.runtime.getManifest().version; + setShowButtons(false); + setShowTabList(false); + setStatus({ + type: 'error', + versionMismatch: { + extensionVersion, + } + }); + return; + } + + const expectedToken = getOrCreateAuthToken(); + const token = params.get('token'); + if (token === expectedToken) { + await connectToMCPRelay(relayUrl); + await handleConnectToTab(); + return; + } + if (token) { + handleReject('Invalid token provided.'); + return; + } + + await connectToMCPRelay(relayUrl); + + // If this is a browser_navigate command, hide the tab list and show simple allow/reject + if (params.get('newTab') === 'true') { + setNewTab(true); + setShowTabList(false); + } else { + await loadTabs(); + } + }; + void runAsync(); + }, []); + + const handleReject = useCallback((message: string) => { + setShowButtons(false); + setShowTabList(false); + setStatus({ type: 'error', message }); + }, []); + + const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => { + const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl }); + if (!response.success) + handleReject(response.error); + }, [handleReject]); + + const loadTabs = useCallback(async () => { + const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); + if (response.success) + setTabs(response.tabs); + else + setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error }); + }, []); + + const handleConnectToTab = useCallback(async (tab?: TabInfo) => { + setShowButtons(false); + setShowTabList(false); + + try { + const response = await chrome.runtime.sendMessage({ + type: 'connectToTab', + mcpRelayUrl, + tabId: tab?.id, + windowId: tab?.windowId, + }); + + if (response?.success) { + setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` }); + } else { + setStatus({ + type: 'error', + message: response?.error || `MCP client "${clientInfo}" failed to connect.` + }); + } + } catch (e) { + setStatus({ + type: 'error', + message: `MCP client "${clientInfo}" failed to connect: ${e}` + }); + } + }, [clientInfo, mcpRelayUrl]); + + useEffect(() => { + const listener = (message: any) => { + if (message.type === 'connectionTimeout') + handleReject('Connection timed out.'); + }; + chrome.runtime.onMessage.addListener(listener); + return () => { + chrome.runtime.onMessage.removeListener(listener); + }; + }, [handleReject]); + + return ( +
+
+ {status && ( +
+ + {showButtons && ( +
+ {newTab ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ )} + + {status?.type === 'connecting' && ( + + )} + + {showTabList && ( +
+
+ Select page to expose to MCP server: +
+
+ {tabs.map(tab => ( + handleConnectToTab(tab)}> + Connect + + } + /> + ))} +
+
+ )} +
+
+ ); +}; + +const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => { + const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md'; + const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest'; + return ( +
+ Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '} + Click here to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '} + See installation instructions for more details. +
+ ); +}; + +const StatusBanner: React.FC<{ status: Status }> = ({ status }) => { + return ( +
+ {'versionMismatch' in status ? ( + + ) : ( + status.message + )} +
+ ); +}; + +// Initialize the React app +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/extension-playwright/src/ui/copyToClipboard.css b/extension-playwright/src/ui/copyToClipboard.css new file mode 100644 index 0000000..d929947 --- /dev/null +++ b/extension-playwright/src/ui/copyToClipboard.css @@ -0,0 +1,39 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.copy-icon { + flex: none; + height: 24px; + width: 24px; + border: none; + outline: none; + color: var(--color-fg-muted); + background: transparent; + padding: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.copy-icon svg { + margin: 0; +} + +.copy-icon:not(:disabled):hover { + background-color: var(--color-btn-selected-bg); +} diff --git a/extension-playwright/src/ui/copyToClipboard.tsx b/extension-playwright/src/ui/copyToClipboard.tsx new file mode 100644 index 0000000..487eaba --- /dev/null +++ b/extension-playwright/src/ui/copyToClipboard.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import * as icons from './icons'; +import './copyToClipboard.css'; + +type CopyToClipboardProps = { + value: string; +}; + +/** + * A copy to clipboard button. + */ +export const CopyToClipboard: React.FunctionComponent = ({ value }) => { + type IconType = 'copy' | 'check' | 'cross'; + const [icon, setIcon] = React.useState('copy'); + + React.useEffect(() => { + setIcon('copy'); + }, [value]); + + React.useEffect(() => { + if (icon === 'check') { + const timeout = setTimeout(() => { + setIcon('copy'); + }, 3000); + return () => clearTimeout(timeout); + } + }, [icon]); + + const handleCopy = React.useCallback(() => { + navigator.clipboard.writeText(value).then(() => { + setIcon('check'); + }, () => { + setIcon('cross'); + }); + }, [value]); + const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy(); + return ; +}; diff --git a/extension-playwright/src/ui/icons.css b/extension-playwright/src/ui/icons.css new file mode 100644 index 0000000..8abcf98 --- /dev/null +++ b/extension-playwright/src/ui/icons.css @@ -0,0 +1,32 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; + margin-right: 7px; + flex: none; +} + +.color-icon-success { + color: var(--color-success-fg) !important; +} + +.color-text-danger { + color: var(--color-danger-fg) !important; +} diff --git a/extension-playwright/src/ui/icons.tsx b/extension-playwright/src/ui/icons.tsx new file mode 100644 index 0000000..fcf74b8 --- /dev/null +++ b/extension-playwright/src/ui/icons.tsx @@ -0,0 +1,49 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import './icons.css'; +import './colors.css'; + +export const cross = () => { + return ; +}; + +export const check = () => { + return ; +}; + +export const copy = () => { + return ; +}; + +export const refresh = () => { + return ; +}; + +export const chevronDown = () => { + return ; +}; diff --git a/extension-playwright/src/ui/status.html b/extension-playwright/src/ui/status.html new file mode 100644 index 0000000..ccc1f04 --- /dev/null +++ b/extension-playwright/src/ui/status.html @@ -0,0 +1,13 @@ + + + + + + Playwright MCP Bridge Status + + + +
+ + + \ No newline at end of file diff --git a/extension-playwright/src/ui/status.tsx b/extension-playwright/src/ui/status.tsx new file mode 100644 index 0000000..ca2e1ef --- /dev/null +++ b/extension-playwright/src/ui/status.tsx @@ -0,0 +1,112 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Button, TabItem } from './tabItem'; + +import type { TabInfo } from './tabItem'; +import { AuthTokenSection } from './authToken'; + +interface ConnectionStatus { + isConnected: boolean; + connectedTabId: number | null; + connectedTab?: TabInfo; +} + +const StatusApp: React.FC = () => { + const [status, setStatus] = useState({ + isConnected: false, + connectedTabId: null + }); + + useEffect(() => { + void loadStatus(); + }, []); + + const loadStatus = async () => { + // Get current connection status from background script + const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' }); + if (connectedTabId) { + const tab = await chrome.tabs.get(connectedTabId); + setStatus({ + isConnected: true, + connectedTabId, + connectedTab: { + id: tab.id!, + windowId: tab.windowId!, + title: tab.title!, + url: tab.url!, + favIconUrl: tab.favIconUrl + } + }); + } else { + setStatus({ + isConnected: false, + connectedTabId: null + }); + } + }; + + const openConnectedTab = async () => { + if (!status.connectedTabId) + return; + await chrome.tabs.update(status.connectedTabId, { active: true }); + window.close(); + }; + + const disconnect = async () => { + await chrome.runtime.sendMessage({ type: 'disconnect' }); + window.close(); + }; + + return ( +
+
+ {status.isConnected && status.connectedTab ? ( +
+
+ Page with connected MCP client: +
+
+ + Disconnect + + } + onClick={openConnectedTab} + /> +
+
+ ) : ( +
+ No MCP clients are currently connected. +
+ )} + +
+
+ ); +}; + +// Initialize the React app +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/extension-playwright/src/ui/tabItem.tsx b/extension-playwright/src/ui/tabItem.tsx new file mode 100644 index 0000000..1483742 --- /dev/null +++ b/extension-playwright/src/ui/tabItem.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +export interface TabInfo { + id: number; + windowId: number; + title: string; + url: string; + favIconUrl?: string; +} + +export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({ + variant, + onClick, + children +}) => { + return ( + + ); +}; + + +export interface TabItemProps { + tab: TabInfo; + onClick?: () => void; + button?: React.ReactNode; +} + +export const TabItem: React.FC = ({ + tab, + onClick, + button +}) => { + return ( +
+ '} + alt='' + className='tab-favicon' + /> +
+
+ {tab.title || 'Untitled'} +
+
{tab.url}
+
+ {button} +
+ ); +}; diff --git a/extension-playwright/src/ui/tsconfig.json b/extension-playwright/src/ui/tsconfig.json new file mode 100644 index 0000000..77839b6 --- /dev/null +++ b/extension-playwright/src/ui/tsconfig.json @@ -0,0 +1,4 @@ +// Help VSCode to find right tsconfig file. +{ + "extends": "../../tsconfig.ui.json" +} diff --git a/extension-playwright/tests/extension.spec.ts b/extension-playwright/tests/extension.spec.ts new file mode 100644 index 0000000..291390e --- /dev/null +++ b/extension-playwright/tests/extension.spec.ts @@ -0,0 +1,336 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import { chromium } from 'playwright'; +import { test as base, expect } from '../../tests/fixtures'; + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { BrowserContext } from 'playwright'; +import type { StartClient } from '../../tests/fixtures'; + +type BrowserWithExtension = { + userDataDir: string; + launch: (mode?: 'disable-extension') => Promise; +}; + +type TestFixtures = { + browserWithExtension: BrowserWithExtension, + pathToExtension: string, + useShortConnectionTimeout: (timeoutMs: number) => void + overrideProtocolVersion: (version: number) => void +}; + +const test = base.extend({ + pathToExtension: async ({}, use) => { + await use(path.resolve(__dirname, '../dist')); + }, + + browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => { + // The flags no longer work in Chrome since + // https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1# + test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium'); + + let browserContext: BrowserContext | undefined; + const userDataDir = testInfo.outputPath('extension-user-data-dir'); + await use({ + userDataDir, + launch: async (mode?: 'disable-extension') => { + browserContext = await chromium.launchPersistentContext(userDataDir, { + channel: mcpBrowser, + // Opening the browser singleton only works in headed. + headless: false, + // Automation disables singleton browser process behavior, which is necessary for the extension. + ignoreDefaultArgs: ['--enable-automation'], + args: mode === 'disable-extension' ? [] : [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }); + + // for manifest v3: + let [serviceWorker] = browserContext.serviceWorkers(); + if (!serviceWorker) + serviceWorker = await browserContext.waitForEvent('serviceworker'); + + return browserContext; + } + }); + await browserContext?.close(); + }, + + useShortConnectionTimeout: async ({}, use) => { + await use((timeoutMs: number) => { + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString(); + }); + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; + }, + + overrideProtocolVersion: async ({}, use) => { + await use((version: number) => { + process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString(); + }); + process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined; + } +}); + +async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { + const { client } = await startClient({ + args: [`--connect-tool`], + config: { + browser: { + userDataDir: browserWithExtension.userDataDir, + } + }, + }); + + expect(await client.callTool({ + name: 'browser_connect', + arguments: { + name: 'extension' + } + })).toHaveResponse({ + result: 'Successfully changed connection method.', + }); + + return client; +} + +async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { + const { client } = await startClient({ + args: [`--extension`], + config: { + browser: { + userDataDir: browserWithExtension.userDataDir, + } + }, + }); + return client; +} + +const testWithOldExtensionVersion = test.extend({ + pathToExtension: async ({}, use, testInfo) => { + const extensionDir = testInfo.outputPath('extension'); + const oldPath = path.resolve(__dirname, '../dist'); + + await fs.promises.cp(oldPath, extensionDir, { recursive: true }); + const manifestPath = path.join(extensionDir, 'manifest.json'); + const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8')); + manifest.version = '0.0.1'; + await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + + await use(extensionDir); + }, +}); + +for (const [mode, startClientMethod] of [ + ['connect-tool', startAndCallConnectTool], + ['extension-flag', startWithExtensionFlag], +] as const) { + + test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const client = await startClientMethod(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const selectorPage = await confirmationPagePromise; + // For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector + await selectorPage.getByRole('button', { name: 'Allow' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + }); + + test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + // Another empty page. + await browserContext.newPage(); + expect(browserContext.pages()).toHaveLength(3); + + const client = await startClientMethod(browserWithExtension, startClient); + expect(browserContext.pages()).toHaveLength(3); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_snapshot', + arguments: { }, + }); + + const selectorPage = await confirmationPagePromise; + expect(browserContext.pages()).toHaveLength(4); + + await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + expect(browserContext.pages()).toHaveLength(4); + }); + + test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => { + useShortConnectionTimeout(100); + + const browserContext = await browserWithExtension.launch(); + + const client = await startClientMethod(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'), + isError: true, + }); + + await confirmationPagePromise; + }); + + testWithOldExtensionVersion(`works with old extension version (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => { + useShortConnectionTimeout(500); + + // Prelaunch the browser, so that it is properly closed after the test. + const browserContext = await browserWithExtension.launch(); + + const client = await startClientMethod(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const selectorPage = await confirmationPagePromise; + // For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector + await selectorPage.getByRole('button', { name: 'Allow' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + }); + + test(`extension needs update (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => { + useShortConnectionTimeout(500); + overrideProtocolVersion(1000); + + // Prelaunch the browser, so that it is properly closed after the test. + const browserContext = await browserWithExtension.launch(); + + const client = await startClientMethod(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const confirmationPage = await confirmationPagePromise; + await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`); + + expect(await navigateResponse).toHaveResponse({ + result: expect.stringContaining('Extension connection timeout.'), + isError: true, + }); + }); + +} + +test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => { + useShortConnectionTimeout(1000); + + const executablePath = test.info().outputPath('echo.sh'); + await fs.promises.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 }); + + const { client } = await startClient({ + args: [`--extension`], + config: { + browser: { + launchOptions: { + executablePath, + }, + } + }, + }); + + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + timeout: 1000, + }); + expect(await navigateResponse).toHaveResponse({ + result: expect.stringContaining('Extension connection timeout.'), + isError: true, + }); + expect(await fs.promises.readFile(test.info().outputPath('output.txt'), 'utf8')).toContain('Custom exec args: chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html?'); +}); + +test(`bypass connection dialog with token`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/status.html'); + const token = await page.locator('.auth-token-code').textContent(); + const [name, value] = token?.split('=') || []; + + const { client } = await startClient({ + args: [`--extension`], + extensionToken: value, + config: { + browser: { + userDataDir: browserWithExtension.userDataDir, + } + }, + }); + + const navigateResponse = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + +}); diff --git a/extension-playwright/tsconfig.json b/extension-playwright/tsconfig.json new file mode 100644 index 0000000..9c22b0b --- /dev/null +++ b/extension-playwright/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "ESNext", + "rootDir": "src", + "outDir": "./dist/lib", + "resolveJsonModule": true, + "types": ["chrome"], + "jsx": "react-jsx", + "jsxImportSource": "react", + "noEmit": true + }, + "include": [ + "src", + ], + "exclude": [ + "src/ui", + ] +} diff --git a/extension-playwright/tsconfig.ui.json b/extension-playwright/tsconfig.ui.json new file mode 100644 index 0000000..f62dd8a --- /dev/null +++ b/extension-playwright/tsconfig.ui.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "ESNext", + "rootDir": "src", + "outDir": "./lib", + "resolveJsonModule": true, + "types": ["chrome"], + "jsx": "react-jsx", + "jsxImportSource": "react", + "noEmit": true, + }, + "include": [ + "src/ui", + ], +} diff --git a/extension-playwright/vite.config.mts b/extension-playwright/vite.config.mts new file mode 100644 index 0000000..89ec56c --- /dev/null +++ b/extension-playwright/vite.config.mts @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + viteStaticCopy({ + targets: [ + { + src: '../../icons/*', + dest: 'icons' + }, + { + src: '../../manifest.json', + dest: '.' + } + ] + }) + ], + root: resolve(__dirname, 'src/ui'), + build: { + outDir: resolve(__dirname, 'dist/'), + emptyOutDir: false, + minify: false, + rollupOptions: { + input: ['src/ui/connect.html', 'src/ui/status.html'], + output: { + manualChunks: undefined, + entryFileNames: 'lib/ui/[name].js', + chunkFileNames: 'lib/ui/[name].js', + assetFileNames: 'lib/ui/[name].[ext]' + } + } + } +}); diff --git a/extension-playwright/vite.sw.config.mts b/extension-playwright/vite.sw.config.mts new file mode 100644 index 0000000..a383e4b --- /dev/null +++ b/extension-playwright/vite.sw.config.mts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve } from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/background.ts'), + fileName: 'lib/background', + formats: ['es'] + }, + outDir: 'dist', + emptyOutDir: false, + minify: false + } +});