UNPKG

playwright-mcp

Version:
355 lines (313 loc) 10.5 kB
import { type BaseBrowserEvent, BrowserEventType, type ClickBrowserEvent, type InputBrowserEvent, type KeyPressBrowserEvent, type OpenPageBrowserEvent, } from './events' import type { Page } from 'playwright' import { getSnowflakeId } from './snowflake' export const initRecording = async ( page: Page, onBrowserEvent: (event: BaseBrowserEvent) => void, ) => { page.addInitScript(() => { if (window.self !== window.top) { return; } function getDom(): string { const snapshot = document.documentElement.cloneNode(true) as HTMLElement // Handle all elements that need state preservation const originalElements = document.querySelectorAll<HTMLElement>('*') const clonedElements = snapshot.querySelectorAll<HTMLElement>('*') // Restore scroll positions in the clone originalElements.forEach((originalElement, index) => { const clonedElement = clonedElements[index] if (!clonedElement) return // Preserve scroll positions as data attributes if (originalElement.scrollLeft || originalElement.scrollTop) { if (originalElement.scrollLeft) { clonedElement.setAttribute( 'qaby-data-scroll-left', originalElement.scrollLeft.toString(), ) } if (originalElement.scrollTop) { clonedElement.setAttribute( 'qaby-data-scroll-top', originalElement.scrollTop.toString(), ) } } // Handle form elements if ( originalElement instanceof HTMLInputElement || originalElement instanceof HTMLTextAreaElement || originalElement instanceof HTMLSelectElement || originalElement.hasAttribute('contenteditable') ) { preserveElementState(originalElement, clonedElement) } }) return snapshot.outerHTML } function preserveElementState( original: HTMLElement, cloned: HTMLElement, ): void { // Handle contenteditable elements if (original.hasAttribute('contenteditable')) { // Use innerHTML instead of textContent to preserve formatting // Escape HTML content before storing as attribute const escapedHTML = original.innerHTML .replace(/&/g, '&amp;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') cloned.setAttribute('qaby-data-contenteditable', escapedHTML) } // Handle form elements if (original instanceof HTMLInputElement) { preserveInputState(original, cloned as HTMLInputElement) } else if (original instanceof HTMLTextAreaElement) { preserveTextAreaState(original, cloned as HTMLTextAreaElement) } else if (original instanceof HTMLSelectElement) { preserveSelectState(original, cloned as HTMLSelectElement) } } function preserveInputState( original: HTMLInputElement, cloned: HTMLInputElement, ): void { switch (original.type) { case 'checkbox': case 'radio': if (original.checked) { cloned.setAttribute('checked', '') } else { cloned.removeAttribute('checked') } if (original.indeterminate) { cloned.setAttribute('qaby-data-indeterminate', 'true') } break case 'range': cloned.setAttribute('value', original.value) break case 'date': case 'datetime-local': case 'month': case 'time': case 'week': if (original.valueAsDate) { cloned.setAttribute( 'qaby-data-value-as-date', original.valueAsDate.toISOString(), ) cloned.setAttribute('value', original.value) } break default: // For text, email, password, etc. cloned.setAttribute('value', original.value) } } function preserveTextAreaState( original: HTMLTextAreaElement, cloned: HTMLTextAreaElement, ): void { cloned.innerHTML = original.value } function preserveSelectState( original: HTMLSelectElement, cloned: HTMLSelectElement, ): void { // First remove any existing selected attributes cloned.querySelectorAll('option').forEach((option) => { option.removeAttribute('selected') }) if (original.multiple) { // For multi-select, preserve selected state of each option Array.from(original.selectedOptions).forEach((option) => { // Find the corresponding option in cloned select by index const optionIndex = Array.from(original.options).indexOf(option) // Use querySelector instead of options property const clonedOption = cloned.querySelector( `option:nth-child(${optionIndex + 1})`, ) if (clonedOption) { clonedOption.setAttribute('selected', '') } }) } else if (original.selectedIndex >= 0) { const clonedOption = cloned.querySelector( `option:nth-child(${original.selectedIndex + 1})`, ) if (clonedOption) { clonedOption.setAttribute('selected', '') } } } function generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0 const v = c === 'x' ? r : (r & 0x3) | 0x8 return v.toString(16) }) } function addAttributesToNode(node: Node): void { if (node.nodeType === window.Node.ELEMENT_NODE) { const element = node as unknown as Element if (!element.getAttribute('uuid')) { element.setAttribute('uuid', generateUUID()) } for (const child of node.childNodes) { addAttributesToNode(child) } } } function removeAttributesFromNode(node: Node): void { if (node.nodeType === window.Node.ELEMENT_NODE) { const element = node as unknown as Element element.removeAttribute('uuid') } } // Event handlers const recordedEvents = new WeakMap<MouseEvent, boolean>(); function handleClick(e: MouseEvent): void { // Check if event was already recorded if (recordedEvents.get(e)) { return; } const target = e.target as Element; if (!target) return; if (target.getAttribute('data-skip-recording')) return; e.stopPropagation(); recordedEvents.set(e, true); addAttributesToNode(document.documentElement); const elementUUID = target.getAttribute('uuid'); const dom = getDom(); window.recordDOM(dom, elementUUID as string).then(() => { // Re-dispatch the event after recording target.dispatchEvent(e); }); removeAttributesFromNode(document.documentElement); } function handleKeyDown(event: KeyboardEvent): void { const dom = getDom() if (['Enter', 'Escape'].includes(event.key)) { window.recordKeyPress(dom, [event.key]) return } if (document.activeElement?.tagName.toLowerCase() === 'input') { if (event.key === 'Tab') { window.recordKeyPress(dom, [event.key]) } return } if (document.activeElement?.tagName.toLowerCase() === 'textarea') { return } const keys: string[] = [] if (event.ctrlKey) keys.push('Control') if (event.shiftKey) keys.push('Shift') if (event.altKey) keys.push('Alt') if (event.metaKey) keys.push('Meta') if (!['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) { keys.push(event.key) } if (keys.includes('Meta') && keys.includes('Tab')) { return } if (keys.length === 1 && keys[0] === 'Meta') { return } if (keys.length > 0) { window.recordKeyPress(dom, keys) } } function handleKeyUp(event: KeyboardEvent): void { if (['Enter', 'Escape'].includes(event.key)) { return; } if ( document.activeElement?.tagName.toLowerCase() !== 'input' && document.activeElement?.tagName.toLowerCase() !== 'textarea' ) { return } const dom = getDom() window.recordInput( dom, document.activeElement.getAttribute('uuid') as string, (document.activeElement as HTMLInputElement).value, ) } window.addEventListener('click', handleClick, { capture: true }) window.addEventListener('keydown', handleKeyDown, { capture: true }) window.addEventListener('keyup', handleKeyUp, { capture: true }) console.log('Recording initialized for window:', window.location.href) }) let buttonClicked = false await page.exposeFunction( 'recordDOM', async (dom: string, elementUUID: string) => { buttonClicked = true const event: ClickBrowserEvent = { eventId: await getSnowflakeId(), type: BrowserEventType.Click, dom, elementUUID, selectors: [`[uuid="${elementUUID}"]`], windowUrl: page.url(), } onBrowserEvent(event) }, ) await page.exposeFunction( 'recordInput', async (dom: string, elementUUID: string, value: string) => { const event: InputBrowserEvent = { eventId: await getSnowflakeId(), type: BrowserEventType.Input, dom, elementUUID, typedText: value, selectors: [`[uuid="${elementUUID}"]`], windowUrl: page.url(), } onBrowserEvent(event) }, ) await page.exposeFunction( 'recordKeyPress', async (dom: string, keys: string[]) => { const event: KeyPressBrowserEvent = { eventId: await getSnowflakeId(), type: BrowserEventType.KeyPress, keys, dom, windowUrl: page.url(), } onBrowserEvent(event) }, ) page.on('load', async () => { if (!buttonClicked) { const event: OpenPageBrowserEvent = { eventId: await getSnowflakeId(), type: BrowserEventType.OpenPage, windowUrl: page.url(), // TODO: Fix navigation handling // title: await page.title(), title: '', // TODO: Fix dom content here dom: '', } onBrowserEvent(event) } buttonClicked = false }) }