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:
parent
eaf8349203
commit
1c23c5f2f7
@ -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.');
|
||||
|
||||
141
src/tab.ts
141
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<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);
|
||||
|
||||
@ -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
244
src/tools/notifications.ts
Normal 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,
|
||||
];
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user