/** * 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 { EventEmitter } from 'events'; import fs from 'fs'; import path from 'path'; import * as playwright from 'playwright'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { logUnhandledError } from './log.js'; import { ManualPromise } from './manualPromise.js'; import { ModalState, WebNotification, RTCConnectionData, RTCStatsSnapshot } from './tools/tool.js'; import { outputFile } from './config.js'; import type { Context } from './context.js'; type PageEx = playwright.Page & { _snapshotForAI: () => Promise; }; const SNAPSHOT_TIMEOUT_MS = 10000; // 10 seconds async function snapshotWithTimeout(page: playwright.Page): Promise { let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((resolve) => { timeoutId = setTimeout(() => { resolve( `[Snapshot timed out after ${SNAPSHOT_TIMEOUT_MS / 1000} seconds]\n` + `This can happen with complex pages, SVG files, or file:// URLs.\n` + `Use browser_take_screenshot to view the page, or disable auto-snapshots with browser_configure_snapshots.` ); }, SNAPSHOT_TIMEOUT_MS); }); try { const result = await Promise.race([ (page as PageEx)._snapshotForAI(), timeoutPromise, ]); return result; } finally { if (timeoutId) clearTimeout(timeoutId); } } export const TabEvents = { modalState: 'modalState' }; export type TabEventsInterface = { [TabEvents.modalState]: [modalState: ModalState]; }; export class Tab extends EventEmitter { readonly context: Context; readonly page: playwright.Page; private _consoleMessages: ConsoleMessage[] = []; private _recentConsoleMessages: ConsoleMessage[] = []; private _requests: Map = new Map(); private _onPageClose: (tab: Tab) => void; private _modalStates: ModalState[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _notifications: WebNotification[] = []; private _notificationIdCounter = 0; private _rtcConnections: RTCConnectionData[] = []; private _rtcConnectionIdCounter = 0; private _rtcStatsPollingInterval: NodeJS.Timeout | undefined; private _rtcMonitoringEnabled = false; constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { super(); this.context = context; this.page = page; this._onPageClose = onPageClose; page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event))); page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error))); page.on('request', request => this._requests.set(request, null)); page.on('response', response => this._requests.set(response.request(), response)); page.on('close', () => this._onClose()); page.on('filechooser', chooser => { this.setModalState({ type: 'fileChooser', description: 'File chooser', fileChooser: chooser, }); }); page.on('dialog', dialog => this._dialogShown(dialog)); page.on('download', download => { void this._downloadStarted(download); }); page.setDefaultNavigationTimeout(60000); page.setDefaultTimeout(5000); // Initialize service worker console capture void this._initializeServiceWorkerConsoleCapture(); // Initialize extension-based console capture void this._initializeExtensionConsoleCapture(); // Initialize notification capture void this._initializeNotificationCapture(); } modalStates(): ModalState[] { return this._modalStates; } setModalState(modalState: ModalState) { this._modalStates.push(modalState); this.emit(TabEvents.modalState, modalState); } clearModalState(modalState: ModalState) { this._modalStates = this._modalStates.filter(state => state !== modalState); } modalStatesMarkdown(): string[] { const result: string[] = ['### Modal state']; if (this._modalStates.length === 0) result.push('- There is no modal state present'); for (const state of this._modalStates) { const tool = this.context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type); result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`); } return result; } private _dialogShown(dialog: playwright.Dialog) { this.setModalState({ type: 'dialog', description: `"${dialog.type()}" dialog with message "${dialog.message()}"`, dialog, }); } private async _downloadStarted(download: playwright.Download) { const entry = { download, finished: false, outputFile: await outputFile(this.context.config, download.suggestedFilename()) }; this._downloads.push(entry); await download.saveAs(entry.outputFile); entry.finished = true; } private _clearCollectedArtifacts() { this._consoleMessages.length = 0; this._recentConsoleMessages.length = 0; this._requests.clear(); } private _handleConsoleMessage(message: ConsoleMessage) { this._consoleMessages.push(message); this._recentConsoleMessages.push(message); // Write to console output file if configured if (this.context.config.consoleOutputFile) this._writeConsoleToFile(message); } private _writeConsoleToFile(message: ConsoleMessage) { try { const consoleFile = this.context.config.consoleOutputFile!; const timestamp = new Date().toISOString(); const url = this.page.url(); const sessionId = this.context.sessionId; const logEntry = `[${timestamp}] [${sessionId}] [${url}] ${message.toString()}\n`; // Ensure directory exists const dir = path.dirname(consoleFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // Append to file (async to avoid blocking) fs.appendFile(consoleFile, logEntry, err => { if (err) { // Log error but don't fail the operation logUnhandledError(err); } }); } catch (error) { // Silently handle errors to avoid breaking browser functionality logUnhandledError(error); } } private async _initializeServiceWorkerConsoleCapture() { try { // Only attempt CDP console capture for Chromium browsers if (this.page.context().browser()?.browserType().name() !== 'chromium') return; const cdpSession = await this.page.context().newCDPSession(this.page); // Enable runtime domain for console API calls await cdpSession.send('Runtime.enable'); // Enable network domain for network-related errors await cdpSession.send('Network.enable'); // Enable security domain for mixed content warnings await cdpSession.send('Security.enable'); // Enable log domain for browser log entries await cdpSession.send('Log.enable'); // Listen for console API calls (includes service worker console messages) cdpSession.on('Runtime.consoleAPICalled', (event: any) => { this._handleServiceWorkerConsole(event); }); // Listen for runtime exceptions (includes service worker errors) cdpSession.on('Runtime.exceptionThrown', (event: any) => { this._handleServiceWorkerException(event); }); // Listen for network failed events cdpSession.on('Network.loadingFailed', (event: any) => { this._handleNetworkError(event); }); // Listen for security state changes (mixed content) cdpSession.on('Security.securityStateChanged', (event: any) => { this._handleSecurityStateChange(event); }); // Listen for log entries (browser-level logs) cdpSession.on('Log.entryAdded', (event: any) => { this._handleLogEntry(event); }); } catch (error) { // Silently handle CDP errors - not all contexts support CDP logUnhandledError(error); } } private _handleServiceWorkerConsole(event: any) { try { // Check if this console event is from a service worker context if (event.executionContextId && event.args && event.args.length > 0) { const message = event.args.map((arg: any) => { if (arg.value !== undefined) return String(arg.value); if (arg.unserializableValue) return arg.unserializableValue; if (arg.objectId) return '[object]'; return ''; }).join(' '); const location = `service-worker:${event.stackTrace?.callFrames?.[0]?.lineNumber || 0}`; const consoleMessage: ConsoleMessage = { type: event.type || 'log', text: message, toString: () => `[${(event.type || 'log').toUpperCase()}] ${message} @ ${location}`, }; this._handleConsoleMessage(consoleMessage); } } catch (error) { logUnhandledError(error); } } private _handleServiceWorkerException(event: any) { try { const exception = event.exceptionDetails; if (exception) { const text = exception.text || exception.exception?.description || 'Service Worker Exception'; const location = `service-worker:${exception.lineNumber || 0}`; const consoleMessage: ConsoleMessage = { type: 'error', text: text, toString: () => `[ERROR] ${text} @ ${location}`, }; this._handleConsoleMessage(consoleMessage); } } catch (error) { logUnhandledError(error); } } private _handleNetworkError(event: any) { try { if (event.errorText && event.requestId) { const consoleMessage: ConsoleMessage = { type: 'error', text: `Network Error: ${event.errorText} (${event.type || 'unknown'})`, toString: () => `[NETWORK ERROR] ${event.errorText} @ ${event.type || 'network'}`, }; this._handleConsoleMessage(consoleMessage); } } catch (error) { logUnhandledError(error); } } private _handleSecurityStateChange(event: any) { try { if (event.securityState === 'insecure' && event.explanations) { for (const explanation of event.explanations) { if (explanation.description && explanation.description.includes('mixed content')) { const consoleMessage: ConsoleMessage = { type: 'error', text: `Security Warning: ${explanation.description}`, toString: () => `[SECURITY] ${explanation.description} @ security-layer`, }; this._handleConsoleMessage(consoleMessage); } } } } catch (error) { logUnhandledError(error); } } private _handleLogEntry(event: any) { try { const entry = event.entry; if (entry && entry.text) { const consoleMessage: ConsoleMessage = { type: entry.level || 'info', text: entry.text, toString: () => `[${(entry.level || 'info').toUpperCase()}] ${entry.text} @ browser-log`, }; this._handleConsoleMessage(consoleMessage); } } catch (error) { logUnhandledError(error); } } private async _initializeExtensionConsoleCapture() { try { // Listen for console messages from the extension await this.page.evaluate(() => { window.addEventListener('message', event => { if (event.data && event.data.type === 'PLAYWRIGHT_CONSOLE_CAPTURE') { const message = event.data.consoleMessage; // Store the message in a global array for Playwright to access if (!(window as any)._playwrightExtensionConsoleMessages) (window as any)._playwrightExtensionConsoleMessages = []; (window as any)._playwrightExtensionConsoleMessages.push(message); } }); }); // Poll for new extension console messages setInterval(() => { void this._checkForExtensionConsoleMessages(); }, 1000); } catch (error) { logUnhandledError(error); } } private async _checkForExtensionConsoleMessages() { try { const newMessages = await this.page.evaluate(() => { if (!(window as any)._playwrightExtensionConsoleMessages) return []; const messages = (window as any)._playwrightExtensionConsoleMessages; (window as any)._playwrightExtensionConsoleMessages = []; return messages; }); for (const message of newMessages) { const consoleMessage: ConsoleMessage = { type: message.type || 'log', text: message.text || '', toString: () => `[${(message.type || 'log').toUpperCase()}] ${message.text} @ ${message.location || message.source}`, }; this._handleConsoleMessage(consoleMessage); } } catch (error) { logUnhandledError(error); } } private async _initializeNotificationCapture() { try { // Expose a function that the injected script can call to report notifications await this.page.exposeFunction('__playwright_notificationShown', (data: { title: string; body?: string; icon?: string; tag?: string; requireInteraction?: boolean; actions?: Array<{ action: string; title: string; icon?: string }>; data?: unknown; }) => { this._handleNotificationShown(data); }); // Inject the Notification interceptor script await this.page.addInitScript(() => { // Store original Notification constructor const OriginalNotification = window.Notification as any; // Create intercepting Notification class class InterceptedNotification extends OriginalNotification { constructor(title: string, options?: NotificationOptions) { super(title, options); // Report notification to Playwright try { const opts = options as any; (window as any).__playwright_notificationShown({ title, body: opts?.body, icon: opts?.icon, tag: opts?.tag, requireInteraction: opts?.requireInteraction, actions: opts?.actions, data: opts?.data, }); } catch (e) { // Ignore errors from reporting } } } // Copy static properties Object.defineProperty(InterceptedNotification, 'permission', { get: () => OriginalNotification.permission, }); (InterceptedNotification as any).requestPermission = OriginalNotification.requestPermission.bind(OriginalNotification); if (OriginalNotification.maxActions !== undefined) (InterceptedNotification as any).maxActions = OriginalNotification.maxActions; // Replace global Notification (window as any).Notification = InterceptedNotification; }); } catch (error) { // Silently handle errors - page may not support exposeFunction logUnhandledError(error); } } async _initializeRTCMonitoring() { try { // Expose callback for new RTC connections await this.page.exposeFunction('__playwright_rtcConnectionCreated', (data: { internalId: string }) => { this._handleRTCConnectionCreated(data); }); // Expose callback for state updates await this.page.exposeFunction('__playwright_rtcConnectionUpdate', (data: { id: string; connectionState: RTCPeerConnectionState; iceConnectionState: RTCIceConnectionState; iceGatheringState: RTCIceGatheringState; signalingState: RTCSignalingState; }) => { this._handleRTCConnectionUpdate(data); }); // Inject RTCPeerConnection interceptor await this.page.addInitScript(() => { // Store original constructor const OriginalRTCPeerConnection = window.RTCPeerConnection; // Map to track connections by internal ID const rtcConnections = new Map(); let connectionCounter = 0; // Intercepting RTCPeerConnection class class InterceptedRTCPeerConnection extends OriginalRTCPeerConnection { private _internalId: string; constructor(configuration?: RTCConfiguration) { super(configuration); // Generate internal ID this._internalId = `browser-rtc-${++connectionCounter}-${Date.now()}`; rtcConnections.set(this._internalId, this); // Report connection creation try { (window as any).__playwright_rtcConnectionCreated({ internalId: this._internalId, }); } catch (e) { // Ignore } // Report state changes const reportState = () => { try { (window as any).__playwright_rtcConnectionUpdate({ id: this._internalId, connectionState: this.connectionState, iceConnectionState: this.iceConnectionState, iceGatheringState: this.iceGatheringState, signalingState: this.signalingState, }); } catch (e) { // Ignore } }; // Listen to all state change events this.addEventListener('connectionstatechange', reportState); this.addEventListener('iceconnectionstatechange', reportState); this.addEventListener('icegatheringstatechange', reportState); this.addEventListener('signalingstatechange', reportState); // Report initial state setTimeout(reportState, 0); // Clean up on close this.addEventListener('connectionstatechange', () => { if (this.connectionState === 'closed') rtcConnections.delete(this._internalId); }); } } // Copy static properties and methods Object.setPrototypeOf(InterceptedRTCPeerConnection, OriginalRTCPeerConnection); Object.setPrototypeOf(InterceptedRTCPeerConnection.prototype, OriginalRTCPeerConnection.prototype); // Replace global RTCPeerConnection (window as any).RTCPeerConnection = InterceptedRTCPeerConnection; // Store connections map globally for stats access (window as any).__rtcConnections = rtcConnections; }); this._rtcMonitoringEnabled = true; } catch (error) { logUnhandledError(error); } } private _handleRTCConnectionCreated(data: { internalId: string }) { const connection: RTCConnectionData & { _internalId: string } = { id: `rtc-${++this._rtcConnectionIdCounter}-${Date.now()}`, origin: this.page.url(), timestamp: Date.now(), connectionState: 'new', iceConnectionState: 'new', iceGatheringState: 'new', signalingState: 'stable', stateHistory: [], _internalId: data.internalId, }; this._rtcConnections.push(connection as any); this.context.addRTCConnection(connection); } private _handleRTCConnectionUpdate(data: { id: string; connectionState: RTCPeerConnectionState; iceConnectionState: RTCIceConnectionState; iceGatheringState: RTCIceGatheringState; signalingState: RTCSignalingState; }) { const connection = this._rtcConnections.find(c => (c as any)._internalId === data.id); if (!connection) return; connection.connectionState = data.connectionState; connection.iceConnectionState = data.iceConnectionState; connection.iceGatheringState = data.iceGatheringState; connection.signalingState = data.signalingState; connection.stateHistory.push({ timestamp: Date.now(), connectionState: data.connectionState, iceConnectionState: data.iceConnectionState, }); // Limit history size if (connection.stateHistory.length > 100) connection.stateHistory.shift(); } private async _requestRTCStats(internalId: string): Promise { try { // Execute in page context to get stats const rawStats = await this.page.evaluate(async (id) => { const rtcConnections = (window as any).__rtcConnections; const pc = rtcConnections?.get(id); if (!pc) return null; const stats = await pc.getStats(); const result: any = {}; stats.forEach((report: any) => { if (!result[report.type]) result[report.type] = []; // Convert to plain object const obj: any = { id: report.id, timestamp: report.timestamp, type: report.type }; for (const key in report) { if (typeof report[key] !== 'function') obj[key] = report[key]; } result[report.type].push(obj); }); return result; }, internalId); if (!rawStats) return; // Find our connection const connection = this._rtcConnections.find(c => (c as any)._internalId === internalId); if (!connection) return; // Parse stats into our structured format connection.lastStats = this._parseRTCStats(rawStats); connection.lastStatsTimestamp = Date.now(); } catch (error) { logUnhandledError(error); } } private _parseRTCStats(rawStats: any): RTCStatsSnapshot { const snapshot: RTCStatsSnapshot = {}; // Parse inbound-rtp for video const inboundVideo = rawStats['inbound-rtp']?.find((r: any) => r.kind === 'video'); if (inboundVideo) { const packetLossRate = inboundVideo.packetsReceived > 0 ? (inboundVideo.packetsLost / inboundVideo.packetsReceived) * 100 : 0; snapshot.inboundVideo = { packetsReceived: inboundVideo.packetsReceived || 0, packetsLost: inboundVideo.packetsLost || 0, packetLossRate: Math.round(packetLossRate * 100) / 100, jitter: (inboundVideo.jitter || 0) * 1000, // Convert to ms bytesReceived: inboundVideo.bytesReceived || 0, bitrate: 0, // Will be calculated from deltas in polling framesPerSecond: inboundVideo.framesPerSecond, framesDecoded: inboundVideo.framesDecoded, frameWidth: inboundVideo.frameWidth, frameHeight: inboundVideo.frameHeight, freezeCount: inboundVideo.freezeCount, totalFreezesDuration: inboundVideo.totalFreezesDuration, }; } // Parse inbound-rtp for audio const inboundAudio = rawStats['inbound-rtp']?.find((r: any) => r.kind === 'audio'); if (inboundAudio) { const packetLossRate = inboundAudio.packetsReceived > 0 ? (inboundAudio.packetsLost / inboundAudio.packetsReceived) * 100 : 0; snapshot.inboundAudio = { packetsReceived: inboundAudio.packetsReceived || 0, packetsLost: inboundAudio.packetsLost || 0, packetLossRate: Math.round(packetLossRate * 100) / 100, jitter: (inboundAudio.jitter || 0) * 1000, bytesReceived: inboundAudio.bytesReceived || 0, bitrate: 0, audioLevel: inboundAudio.audioLevel, concealedSamples: inboundAudio.concealedSamples, }; } // Parse outbound-rtp for video const outboundVideo = rawStats['outbound-rtp']?.find((r: any) => r.kind === 'video'); if (outboundVideo) { snapshot.outboundVideo = { packetsSent: outboundVideo.packetsSent || 0, bytesSent: outboundVideo.bytesSent || 0, bitrate: 0, framesPerSecond: outboundVideo.framesPerSecond, framesEncoded: outboundVideo.framesEncoded, frameWidth: outboundVideo.frameWidth, frameHeight: outboundVideo.frameHeight, qualityLimitationReason: outboundVideo.qualityLimitationReason, }; } // Parse outbound-rtp for audio const outboundAudio = rawStats['outbound-rtp']?.find((r: any) => r.kind === 'audio'); if (outboundAudio) { snapshot.outboundAudio = { packetsSent: outboundAudio.packetsSent || 0, bytesSent: outboundAudio.bytesSent || 0, bitrate: 0, }; } // Parse candidate-pair (get the selected/active pair) const candidatePairs = rawStats['candidate-pair'] || []; const activePair = candidatePairs.find((p: any) => p.state === 'succeeded') || candidatePairs[0]; if (activePair) { snapshot.candidatePair = { state: activePair.state, localCandidateType: activePair.localCandidateType || 'unknown', remoteCandidateType: activePair.remoteCandidateType || 'unknown', currentRoundTripTime: activePair.currentRoundTripTime, availableOutgoingBitrate: activePair.availableOutgoingBitrate, }; } return snapshot; } enableRTCStatsPolling(intervalMs: number = 1000) { if (this._rtcStatsPollingInterval) clearInterval(this._rtcStatsPollingInterval); this._rtcStatsPollingInterval = setInterval(async () => { // Poll stats for all active connections for (const connection of this._rtcConnections) { if (connection.connectionState !== 'closed') await this._requestRTCStats((connection as any)._internalId); } }, intervalMs); } disableRTCStatsPolling() { if (this._rtcStatsPollingInterval) { clearInterval(this._rtcStatsPollingInterval); this._rtcStatsPollingInterval = undefined; } } rtcConnections(): RTCConnectionData[] { return this._rtcConnections; } getRTCConnection(id: string): RTCConnectionData | undefined { return this._rtcConnections.find(c => c.id === id); } clearRTCConnections() { this.disableRTCStatsPolling(); this._rtcConnections.length = 0; this._rtcConnectionIdCounter = 0; } isRTCMonitoringEnabled(): boolean { return this._rtcMonitoringEnabled; } private _handleNotificationShown(data: { title: string; body?: string; icon?: string; tag?: string; requireInteraction?: boolean; actions?: Array<{ action: string; title: string; icon?: string }>; data?: unknown; }) { const notification: WebNotification = { id: `notif-${++this._notificationIdCounter}-${Date.now()}`, title: data.title, body: data.body || '', icon: data.icon, tag: data.tag, origin: this.page.url(), timestamp: Date.now(), requireInteraction: data.requireInteraction, actions: data.actions, data: data.data, clicked: false, closed: false, }; this._notifications.push(notification); // Set modal state so tools know there's a notification to handle this.setModalState({ type: 'notification', description: `Notification "${notification.title}" from ${new URL(notification.origin).hostname}`, notification, }); // Also notify context for aggregation this.context.addNotification(notification); } notifications(): WebNotification[] { return this._notifications; } getNotification(id: string): WebNotification | undefined { return this._notifications.find(n => n.id === id); } markNotificationClicked(id: string) { const notification = this._notifications.find(n => n.id === id); if (notification) { notification.clicked = true; // Clear modal state for this notification const modalState = this._modalStates.find(s => s.type === 'notification' && s.notification.id === id); if (modalState) this.clearModalState(modalState); } } markNotificationClosed(id: string) { const notification = this._notifications.find(n => n.id === id); if (notification) { notification.closed = true; // Clear modal state for this notification const modalState = this._modalStates.find(s => s.type === 'notification' && s.notification.id === id); if (modalState) this.clearModalState(modalState); } } clearNotifications() { // Clear notification modal states this._modalStates = this._modalStates.filter(s => s.type !== 'notification'); this._notifications.length = 0; } private _onClose() { this._clearCollectedArtifacts(); this._onPageClose(this); } async title(): Promise { return await callOnPageNoTrace(this.page, page => page.title()); } async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise { await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError)); } async navigate(url: string) { this._clearCollectedArtifacts(); const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError)); try { await this.page.goto(url, { waitUntil: 'domcontentloaded' }); } catch (_e: unknown) { const e = _e as Error; const mightBeDownload = e.message.includes('net::ERR_ABORTED') // chromium || e.message.includes('Download is starting'); // firefox + webkit if (!mightBeDownload) throw e; // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit const download = await Promise.race([ downloadEvent, new Promise(resolve => setTimeout(resolve, 3000)), ]); if (!download) throw e; // Make sure other "download" listeners are notified first. await new Promise(resolve => setTimeout(resolve, 500)); return; } // Cap load event to 5 seconds, the page is operational at this point. await this.waitForLoadState('load', { timeout: 5000 }); } consoleMessages(): ConsoleMessage[] { return this._consoleMessages; } requests(): Map { return this._requests; } private _takeRecentConsoleMarkdown(): string[] { if (!this._recentConsoleMessages.length) return []; const result = this._recentConsoleMessages.map(message => { return `- ${trim(message.toString(), 100)}`; }); return [`### New console messages`, ...result, '']; } private _listDownloadsMarkdown(): string[] { if (!this._downloads.length) return []; const result: string[] = ['### Downloads']; for (const entry of this._downloads) { if (entry.finished) result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`); else result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`); } result.push(''); return result; } async captureSnapshot(): Promise { const result: string[] = []; if (this.modalStates().length) { result.push(...this.modalStatesMarkdown()); return result.join('\n'); } result.push(...this._takeRecentConsoleMarkdown()); result.push(...this._listDownloadsMarkdown()); await this._raceAgainstModalStates(async () => { const snapshot = await snapshotWithTimeout(this.page); result.push( `### Page state`, `- Page URL: ${this.page.url()}`, `- Page Title: ${await this.page.title()}`, `- Page Snapshot:`, '```yaml', snapshot, '```', ); }); return result.join('\n'); } private _javaScriptBlocked(): boolean { return this._modalStates.some(state => state.type === 'dialog'); } private async _raceAgainstModalStates(action: () => Promise): Promise { if (this.modalStates().length) return this.modalStates()[0]; const promise = new ManualPromise(); const listener = (modalState: ModalState) => promise.resolve(modalState); this.once(TabEvents.modalState, listener); return await Promise.race([ action().then(() => { this.off(TabEvents.modalState, listener); return undefined; }), promise, ]); } async waitForCompletion(callback: () => Promise) { await this._raceAgainstModalStates(() => waitForCompletion(this, callback)); } async refLocator(params: { element: string, ref: string }): Promise { return (await this.refLocators([params]))[0]; } async refLocators(params: { element: string, ref: string }[]): Promise { const snapshot = await snapshotWithTimeout(this.page); return params.map(param => { if (!snapshot.includes(`[ref=${param.ref}]`)) throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`); return this.page.locator(`aria-ref=${param.ref}`).describe(param.element); }); } async waitForTimeout(time: number) { if (this._javaScriptBlocked()) { await new Promise(f => setTimeout(f, time)); return; } await callOnPageNoTrace(this.page, page => { return page.evaluate(() => new Promise(f => setTimeout(f, 1000))); }); } } export type ConsoleMessage = { type: ReturnType | undefined; text: string; toString(): string; }; function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage { return { type: message.type(), text: message.text(), toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`, }; } function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage { if (errorOrValue instanceof Error) { return { type: undefined, text: errorOrValue.message, toString: () => errorOrValue.stack || errorOrValue.message, }; } return { type: undefined, text: String(errorOrValue), toString: () => String(errorOrValue), }; } function trim(text: string, maxLength: number) { if (text.length <= maxLength) return text; return text.slice(0, maxLength) + '...'; }