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.
This commit is contained in:
Ryan Malloy 2026-01-12 19:31:26 -07:00
parent eaf8349203
commit 1c23c5f2f7
5 changed files with 440 additions and 3 deletions

View File

@ -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<Tab> {
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<playwright.BrowserContext | undefined> {
if (!this._browserContextPromise)
return undefined;
const { browserContext } = await this._browserContextPromise;
return browserContext;
}
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this._closeBrowserContextPromise)
throw new Error('Another browser context is being closed.');

View File

@ -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<TabEventsInterface> {
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<TabEventsInterface> {
// 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<TabEventsInterface> {
}
}
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);

View File

@ -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<any>[] = [
...keyboard,
...navigate,
...network,
...notifications,
...mouse,
...pdf,
...requests,

244
src/tools/notifications.ts Normal file
View File

@ -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,
];

View File

@ -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<Input extends z.Schema = z.Schema> = {
capability: ToolCapability;