From 1c23c5f2f75410c13695c7208ebcdc73cfd00606 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 12 Jan 2026 19:31:26 -0700 Subject: [PATCH] feat: add Web Push Notification support with pull-based retrieval Adds comprehensive Web Notification API support: - Intercepts Notification constructor via page.addInitScript() - Bridges notification data to Node.js via page.exposeFunction() - Stores notifications in Tab and Context for pull-based retrieval New MCP tools: - browser_configure_notifications: grant/deny permissions per origin - browser_list_notifications: query captured notifications - browser_handle_notification: click or close notifications - browser_wait_notification: wait for matching notification - browser_clear_notifications: clear notification history Architecture uses modal state pattern for notification handling, consistent with existing dialog and file chooser patterns. --- src/context.ts | 33 ++++- src/tab.ts | 141 ++++++++++++++++++++- src/tools.ts | 2 + src/tools/notifications.ts | 244 +++++++++++++++++++++++++++++++++++++ src/tools/tool.ts | 23 +++- 5 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 src/tools/notifications.ts diff --git a/src/context.ts b/src/context.ts index 33297b9..cfa2834 100644 --- a/src/context.ts +++ b/src/context.ts @@ -24,7 +24,7 @@ import { EnvironmentIntrospector } from './environmentIntrospection.js'; import { RequestInterceptor, RequestInterceptorOptions } from './requestInterceptor.js'; import { ArtifactManagerRegistry } from './artifactManager.js'; -import type { Tool } from './tools/tool.js'; +import type { Tool, WebNotification } from './tools/tool.js'; import type { FullConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; import type { InjectionConfig } from './tools/codeInjection.js'; @@ -94,6 +94,9 @@ export class Context { // Code injection for debug toolbar and custom scripts injectionConfig: InjectionConfig | undefined; + // Browser notifications storage + private _notifications: WebNotification[] = []; + constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory, environmentIntrospector?: EnvironmentIntrospector) { this.tools = tools; this.config = config; @@ -157,6 +160,23 @@ export class Context { return this._currentTab; } + // Notification management methods + addNotification(notification: WebNotification): void { + this._notifications.push(notification); + } + + notifications(): WebNotification[] { + return this._notifications; + } + + getNotification(id: string): WebNotification | undefined { + return this._notifications.find(n => n.id === id); + } + + clearNotifications(): void { + this._notifications.length = 0; + } + async newTab(): Promise { const { browserContext } = await this._ensureBrowserContext(); const page = await browserContext.newPage(); @@ -358,6 +378,17 @@ export class Context { return this._browserContextPromise; } + /** + * Returns the existing browser context if one has been created, or undefined. + * Does not create a new context - use for operations that need an existing context. + */ + async existingBrowserContext(): Promise { + if (!this._browserContextPromise) + return undefined; + const { browserContext } = await this._browserContextPromise; + return browserContext; + } + private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { if (this._closeBrowserContextPromise) throw new Error('Another browser context is being closed.'); diff --git a/src/tab.ts b/src/tab.ts index 31e329a..aa11f11 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -21,7 +21,7 @@ import * as playwright from 'playwright'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { logUnhandledError } from './log.js'; import { ManualPromise } from './manualPromise.js'; -import { ModalState } from './tools/tool.js'; +import { ModalState, WebNotification } from './tools/tool.js'; import { outputFile } from './config.js'; import type { Context } from './context.js'; @@ -47,6 +47,8 @@ export class Tab extends EventEmitter { private _onPageClose: (tab: Tab) => void; private _modalStates: ModalState[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; + private _notifications: WebNotification[] = []; + private _notificationIdCounter = 0; constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { super(); @@ -77,6 +79,9 @@ export class Tab extends EventEmitter { // Initialize extension-based console capture void this._initializeExtensionConsoleCapture(); + + // Initialize notification capture + void this._initializeNotificationCapture(); } modalStates(): ModalState[] { @@ -375,6 +380,140 @@ export class Tab extends EventEmitter { } } + 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); + } + } + + 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); diff --git a/src/tools.ts b/src/tools.ts index b6943af..8d0028d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -26,6 +26,7 @@ import install from './tools/install.js'; import keyboard from './tools/keyboard.js'; import navigate from './tools/navigate.js'; import network from './tools/network.js'; +import notifications from './tools/notifications.js'; import pdf from './tools/pdf.js'; import requests from './tools/requests.js'; import snapshot from './tools/snapshot.js'; @@ -52,6 +53,7 @@ export const allTools: Tool[] = [ ...keyboard, ...navigate, ...network, + ...notifications, ...mouse, ...pdf, ...requests, diff --git a/src/tools/notifications.ts b/src/tools/notifications.ts new file mode 100644 index 0000000..27e0ea6 --- /dev/null +++ b/src/tools/notifications.ts @@ -0,0 +1,244 @@ +/** + * 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 { z } from 'zod'; +import { defineTool, defineTabTool } from './tool.js'; + +/** + * Configure notification permissions for specific origins. + * Uses browserContext.grantPermissions() to grant or deny notification access. + */ +const configureNotifications = defineTool({ + capability: 'core', + + schema: { + name: 'browser_configure_notifications', + title: 'Configure notification permissions', + description: 'Grant or deny notification permissions for specific origins. This controls whether websites can show browser notifications.', + inputSchema: z.object({ + origins: z.array(z.object({ + origin: z.string().describe('Origin URL to configure (e.g., "https://example.com")'), + permission: z.enum(['granted', 'denied']).describe('Permission to set: "granted" allows notifications, "denied" blocks them'), + })).describe('List of origins and their notification permissions'), + }), + type: 'destructive', + }, + + handle: async (context, params, response) => { + const browserContext = await context.existingBrowserContext(); + if (!browserContext) + throw new Error('No browser context available. Navigate to a page first.'); + + const results: string[] = []; + + for (const { origin, permission } of params.origins) { + if (permission === 'granted') { + await browserContext.grantPermissions(['notifications'], { origin }); + results.push(`Granted notification permission to ${origin}`); + } else { + // Playwright doesn't have a direct "deny" - we clear permissions instead + await browserContext.clearPermissions(); + results.push(`Cleared permissions for ${origin} (notifications denied by default)`); + } + } + + response.addResult(results.join('\n')); + }, +}); + +/** + * List all captured notifications from the current session. + */ +const listNotifications = defineTool({ + capability: 'core', + + schema: { + name: 'browser_list_notifications', + title: 'List browser notifications', + description: 'List all notifications that have been shown during this browser session. Returns notification details including title, body, origin, and status.', + inputSchema: z.object({ + origin: z.string().optional().describe('Filter notifications by origin URL'), + includeHandled: z.boolean().optional().describe('Include notifications that have been clicked or closed (default: true)'), + }), + type: 'readOnly', + }, + + handle: async (context, params, response) => { + let notifications = context.notifications(); + + // Filter by origin if specified + if (params.origin) { + const originFilter = params.origin.toLowerCase(); + notifications = notifications.filter(n => n.origin.toLowerCase().includes(originFilter)); + } + + // Filter out handled notifications unless requested + if (params.includeHandled === false) + notifications = notifications.filter(n => !n.clicked && !n.closed); + + if (notifications.length === 0) { + response.addResult('No notifications captured.'); + return; + } + + const result = ['### Browser Notifications', '']; + for (const notif of notifications) { + const status = notif.clicked ? ' (clicked)' : notif.closed ? ' (closed)' : ''; + const hostname = new URL(notif.origin).hostname; + result.push(`- **${notif.title}**${status}`); + result.push(` - ID: \`${notif.id}\``); + result.push(` - Body: ${notif.body || '(empty)'}`); + result.push(` - Origin: ${hostname}`); + result.push(` - Time: ${new Date(notif.timestamp).toISOString()}`); + if (notif.actions && notif.actions.length > 0) + result.push(` - Actions: ${notif.actions.map(a => a.title).join(', ')}`); + result.push(''); + } + + response.addResult(result.join('\n')); + }, +}); + +/** + * Handle a notification by clicking or closing it. + */ +const handleNotification = defineTabTool({ + capability: 'core', + + schema: { + name: 'browser_handle_notification', + title: 'Handle a browser notification', + description: 'Click or close a browser notification. Use browser_list_notifications to see available notifications and their IDs.', + inputSchema: z.object({ + notificationId: z.string().describe('The notification ID to handle (from browser_list_notifications)'), + action: z.enum(['click', 'close']).describe('Action to take: "click" simulates clicking the notification, "close" dismisses it'), + actionButton: z.string().optional().describe('For notifications with action buttons, specify which action to click'), + }), + type: 'destructive', + }, + + handle: async (tab, params, response) => { + const notification = tab.getNotification(params.notificationId); + if (!notification) + throw new Error(`Notification with ID "${params.notificationId}" not found. Use browser_list_notifications to see available notifications.`); + + if (notification.clicked || notification.closed) + throw new Error(`Notification "${params.notificationId}" has already been handled.`); + + if (params.action === 'click') { + tab.markNotificationClicked(params.notificationId); + response.addResult(`Clicked notification "${notification.title}"`); + } else { + tab.markNotificationClosed(params.notificationId); + response.addResult(`Closed notification "${notification.title}"`); + } + + response.setIncludeSnapshot(); + }, + + clearsModalState: 'notification', +}); + +/** + * Wait for a notification to appear matching specified criteria. + */ +const waitForNotification = defineTabTool({ + capability: 'core', + + schema: { + name: 'browser_wait_notification', + title: 'Wait for a notification', + description: 'Wait for a browser notification to appear, optionally matching specific criteria. Returns when a matching notification is shown or timeout is reached.', + inputSchema: z.object({ + title: z.string().optional().describe('Wait for notification with this exact title'), + titleContains: z.string().optional().describe('Wait for notification with title containing this text'), + origin: z.string().optional().describe('Wait for notification from this origin'), + timeout: z.number().optional().describe('Maximum time to wait in milliseconds (default: 30000)'), + }), + type: 'readOnly', + }, + + handle: async (tab, params, response) => { + const timeout = params.timeout || 30000; + const startTime = Date.now(); + const pollInterval = 500; + + const matches = (notif: { title: string; origin: string; clicked?: boolean; closed?: boolean }) => { + if (notif.clicked || notif.closed) + return false; + if (params.title && notif.title !== params.title) + return false; + if (params.titleContains && !notif.title.includes(params.titleContains)) + return false; + if (params.origin && !notif.origin.includes(params.origin)) + return false; + return true; + }; + + // Check existing notifications first + let notification = tab.notifications().find(matches); + if (notification) { + response.addResult(`Found existing notification: "${notification.title}" (ID: ${notification.id})`); + return; + } + + // Poll for new notifications + while (Date.now() - startTime < timeout) { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + notification = tab.notifications().find(matches); + if (notification) { + response.addResult(`Notification received: "${notification.title}" (ID: ${notification.id})`); + return; + } + } + + throw new Error(`Timeout waiting for notification after ${timeout}ms`); + }, +}); + +/** + * Clear all captured notifications. + */ +const clearNotifications = defineTool({ + capability: 'core', + + schema: { + name: 'browser_clear_notifications', + title: 'Clear notification history', + description: 'Clear all captured notifications from the session history.', + inputSchema: z.object({}), + type: 'destructive', + }, + + handle: async (context, params, response) => { + const count = context.notifications().length; + context.clearNotifications(); + + // Also clear from all tabs + for (const tab of context.tabs()) + tab.clearNotifications(); + + response.addResult(`Cleared ${count} notification(s) from history.`); + }, +}); + +export default [ + configureNotifications, + listNotifications, + handleNotification, + waitForNotification, + clearNotifications, +]; diff --git a/src/tools/tool.ts b/src/tools/tool.ts index aa0628b..ebadf35 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -34,7 +34,28 @@ export type DialogModalState = { dialog: playwright.Dialog; }; -export type ModalState = FileUploadModalState | DialogModalState; +export type WebNotification = { + id: string; + title: string; + body: string; + icon?: string; + tag?: string; + origin: string; + timestamp: number; + requireInteraction?: boolean; + actions?: Array<{ action: string; title: string; icon?: string }>; + data?: unknown; + clicked?: boolean; + closed?: boolean; +}; + +export type NotificationModalState = { + type: 'notification'; + description: string; + notification: WebNotification; +}; + +export type ModalState = FileUploadModalState | DialogModalState | NotificationModalState; export type Tool = { capability: ToolCapability;