diff --git a/README.md b/README.md index de2804b..220c536 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit - **🎬 Intelligent video recording**. Smart pause/resume modes eliminate dead time for professional demo videos with automatic viewport matching. - **🎨 Custom code injection**. Inject JavaScript/CSS into pages for enhanced automation, with memory-leak-free cleanup and session persistence. - **📁 Centralized artifact management**. Session-based organization of screenshots, videos, and PDFs with comprehensive audit logging. +- **📦 PWA analysis and download**. Inspect Progressive Web Apps and download complete packages including manifest, icons, service worker, and cached resources. - **🔧 Enterprise-ready**. Memory leak prevention, comprehensive error handling, and production-tested browser automation patterns. ### Requirements @@ -631,6 +632,14 @@ http.createServer(async (req, res) => { +- **browser_clear_network_conditions** + - Title: Remove network throttling + - Description: Remove all network throttling and restore full network speed. Equivalent to setting preset "no-throttle". + - Parameters: None + - Read-only: **false** + + + - **browser_clear_notifications** - Title: Clear notification history - Description: Clear all captured notifications from the session history. @@ -655,6 +664,23 @@ http.createServer(async (req, res) => { +- **browser_clear_storage** + - Title: Clear web storage + - Description: Clear localStorage, sessionStorage, or both for the current page. + - Parameters: + - `type` (string): Storage type to clear: "local", "session", or "both" + - Read-only: **false** + + + +- **browser_clear_webrtc_data** + - Title: Clear WebRTC data + - Description: Clear all captured WebRTC connection data and statistics from session history. Also stops monitoring if active. + - Parameters: None + - Read-only: **false** + + + - **browser_click** - Title: Click - Description: Perform click on a web page. Returns page snapshot after click (configurable via browser_configure_snapshots). Use browser_snapshot for explicit full snapshots. @@ -787,6 +813,17 @@ Note: filterPreset and jqExpression are mutually exclusive. Preset takes precede +- **browser_delete_cookies** + - Title: Delete browser cookies + - Description: Delete cookies by name, domain, or path. If no filters provided, clears all cookies. + - Parameters: + - `name` (string, optional): Delete cookies with this name + - `domain` (string, optional): Delete cookies for this domain + - `path` (string, optional): Delete cookies with this path + - Read-only: **false** + + + - **browser_disable_debug_toolbar** - Title: Disable Debug Toolbar - Description: Disable the debug toolbar for the current session @@ -908,6 +945,23 @@ This is the FIRST conversational browser automation MCP server! +- **browser_get_cookies** + - Title: Get browser cookies + - Description: List all cookies for the current browser context. Optionally filter by domain or URL. + - Parameters: + - `urls` (array, optional): Filter cookies by specific URLs. If not provided, returns all cookies. + - Read-only: **true** + + + +- **browser_get_network_conditions** + - Title: Get current network throttling settings + - Description: Get the current network throttling configuration. Returns preset name if using a preset, or custom values if manually configured. + - Parameters: None + - Read-only: **true** + + + - **browser_get_requests** - Title: Get captured requests - Description: Retrieve and analyze captured HTTP requests with pagination support. Shows timing, status codes, headers, and bodies. Large request lists are automatically paginated for better performance. @@ -926,6 +980,37 @@ This is the FIRST conversational browser automation MCP server! +- **browser_get_storage** + - Title: Get web storage contents + - Description: Get all key-value pairs from localStorage or sessionStorage for the current page. + - Parameters: + - `type` (string): Storage type: "local" for localStorage, "session" for sessionStorage + - `key` (string, optional): Get a specific key value instead of all items + - Read-only: **true** + + + +- **browser_get_webrtc_connections** + - Title: Get WebRTC connections + - Description: List all WebRTC connections captured during this session. Shows connection states, ICE states, and origin. Use browser_get_webrtc_stats for detailed statistics. + - Parameters: + - `connectionState` (string, optional): Filter by connection state + - `iceConnectionState` (string, optional): Filter by ICE connection state + - `origin` (string, optional): Filter by origin URL + - Read-only: **true** + + + +- **browser_get_webrtc_stats** + - Title: Get WebRTC statistics + - Description: Get detailed real-time statistics for WebRTC connections. Includes bitrate, packet loss, jitter, RTT, frames per second, and quality metrics. Essential for diagnosing call quality issues. + - Parameters: + - `connectionId` (string, optional): Specific connection ID (from browser_get_webrtc_connections). If omitted, shows stats for all connections. + - `includeRaw` (boolean, optional): Include raw stats data for debugging (default: false) + - Read-only: **true** + + + - **browser_grant_permissions** - Title: Grant browser permissions at runtime - Description: Grant browser permissions at runtime without restarting the browser. This is faster than using browser_configure which requires a browser restart. @@ -1172,6 +1257,27 @@ Full API: See MODEL-COLLABORATION-API.md +- **browser_pwa_download** + - Title: Download PWA package + - Description: Download complete Progressive Web App (PWA) package including manifest, icons, service worker, and cached resources. + - Parameters: + - `outputDir` (string, optional): Custom output directory path. If not specified, uses default artifact directory. + - `includeIcons` (boolean, optional): Download all icon sizes from manifest (default: true) + - `includeCache` (boolean, optional): Download cached resources from CacheStorage (default: true) + - `createZip` (boolean, optional): Create zip archive of downloaded content (default: false) + - `maxCacheSize` (number, optional): Maximum total cache size to download in MB (default: 100) + - Read-only: **false** + + + +- **browser_pwa_info** + - Title: Get PWA information + - Description: Detect and report Progressive Web App (PWA) metadata for the current page including manifest, service worker, and cache information. + - Parameters: None + - Read-only: **true** + + + - **browser_recording_status** - Title: Get video recording status - Description: Check if video recording is currently enabled and get recording details. Use this to verify recording is active before performing actions, or to check output directory and settings. @@ -1225,6 +1331,23 @@ Full API: See MODEL-COLLABORATION-API.md +- **browser_set_cookie** + - Title: Set a browser cookie + - Description: Set a cookie with specified name, value, and optional attributes. Requires either url or domain+path. + - Parameters: + - `name` (string): Cookie name + - `value` (string): Cookie value + - `url` (string, optional): URL to associate with the cookie. Either url or domain must be specified. + - `domain` (string, optional): Cookie domain. Either url or domain must be specified. + - `path` (string, optional): Cookie path (default: "/") + - `expires` (number, optional): Unix timestamp in seconds for cookie expiration. -1 for session cookie. + - `httpOnly` (boolean, optional): Whether the cookie is HTTP only (default: false) + - `secure` (boolean, optional): Whether the cookie is secure (default: false) + - `sameSite` (string, optional): SameSite attribute (default: "Lax") + - Read-only: **false** + + + - **browser_set_device_motion** - Title: Set device motion sensors - Description: Override accelerometer and gyroscope sensor values. Affects the DeviceMotionEvent API. @@ -1292,6 +1415,29 @@ Full API: See MODEL-COLLABORATION-API.md +- **browser_set_network_conditions** + - Title: Set network throttling conditions + - Description: Simulate slow network conditions using Chrome DevTools Protocol. Choose from presets or specify custom values. + +**Presets:** +- offline: Block all network requests +- slow-3g: ~400 kbps, 2s latency (poor mobile) +- fast-3g: ~1.5 Mbps, 563ms latency (typical 3G) +- regular-4g: ~12 Mbps, 170ms latency (LTE) +- wifi: ~24 Mbps, 28ms latency (home WiFi) +- no-throttle: Remove all throttling + +**Note:** This feature requires a Chromium-based browser (Chrome, Edge). Firefox and WebKit are not supported. + - Parameters: + - `preset` (string, optional): Network condition preset. Use "offline" to block all requests, "slow-3g" for poor mobile, "fast-3g" for typical mobile, "regular-4g" for LTE, "wifi" for home WiFi, or "no-throttle" to remove throttling. + - `downloadThroughput` (number, optional): Custom download speed in bytes/second. Use -1 for no throttling. Overrides preset if specified. + - `uploadThroughput` (number, optional): Custom upload speed in bytes/second. Use -1 for no throttling. Overrides preset if specified. + - `latency` (number, optional): Custom latency in milliseconds to add to each request. Overrides preset if specified. + - `offline` (boolean, optional): Set to true to simulate offline mode. Overrides preset if specified. + - Read-only: **false** + + + - **browser_set_offline** - Title: Set browser offline mode - Description: Toggle browser offline mode on/off (equivalent to DevTools offline checkbox) @@ -1314,6 +1460,17 @@ Full API: See MODEL-COLLABORATION-API.md +- **browser_set_storage** + - Title: Set web storage item + - Description: Set a key-value pair in localStorage or sessionStorage for the current page. + - Parameters: + - `type` (string): Storage type: "local" for localStorage, "session" for sessionStorage + - `key` (string): Storage key + - `value` (string): Storage value (will be stored as string) + - Read-only: **false** + + + - **browser_snapshot** - Title: Page snapshot - Description: Capture complete accessibility snapshot of the current page. Always returns full snapshot regardless of session snapshot configuration. Better than screenshot for understanding page structure. @@ -1346,6 +1503,15 @@ Full API: See MODEL-COLLABORATION-API.md +- **browser_start_webrtc_monitoring** + - Title: Start WebRTC monitoring + - Description: Enable real-time WebRTC connection monitoring. Intercepts RTCPeerConnection API to track connection states and collect statistics. Required before using other WebRTC tools. + - Parameters: + - `statsPollingInterval` (number, optional): Stats collection interval in milliseconds (default: 1000ms). Lower values give more frequent updates but use more CPU. + - Read-only: **false** + + + - **browser_status** - Title: Get browser status and capabilities - Description: Get current browser configuration status including mode (isolated/persistent), profile path, and available capabilities like Push API support. @@ -1362,6 +1528,14 @@ Full API: See MODEL-COLLABORATION-API.md +- **browser_stop_webrtc_monitoring** + - Title: Stop WebRTC monitoring + - Description: Stop collecting WebRTC statistics. Captured connection data is preserved and can still be queried. Use browser_clear_webrtc_data to also clear historical data. + - Parameters: None + - Read-only: **false** + + + - **browser_take_screenshot** - Title: Take a screenshot - Description: Take a screenshot of the current page. Images exceeding 8000 pixels in either dimension will be rejected unless allowLargeImages=true. You can't perform actions based on the screenshot, use browser_snapshot for actions. diff --git a/src/tools.ts b/src/tools.ts index 5c6d4ae..a86a61f 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -29,6 +29,7 @@ import network from './tools/network.js'; import networkThrottle from './tools/network-throttle.js'; import notifications from './tools/notifications.js'; import pdf from './tools/pdf.js'; +import pwa from './tools/pwa.js'; import sensors from './tools/sensors.js'; import requests from './tools/requests.js'; import snapshot from './tools/snapshot.js'; @@ -61,6 +62,7 @@ export const allTools: Tool[] = [ ...notifications, ...mouse, ...pdf, + ...pwa, ...requests, ...screenshot, ...sensors, diff --git a/src/tools/pwa.ts b/src/tools/pwa.ts new file mode 100644 index 0000000..63e4501 --- /dev/null +++ b/src/tools/pwa.ts @@ -0,0 +1,753 @@ +/** + * 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 * as fs from 'fs'; +import * as path from 'path'; +import { z } from 'zod'; +import { defineTabTool } from './tool.js'; +import { outputFile } from '../config.js'; +import { ArtifactManagerRegistry } from '../artifactManager.js'; +import { sanitizeForFilePath } from './utils.js'; + +// Types for PWA manifest (subset of Web App Manifest spec) +interface PWAIcon { + src: string; + sizes?: string; + type?: string; + purpose?: string; +} + +interface PWAManifest { + name?: string; + short_name?: string; + start_url?: string; + display?: string; + theme_color?: string; + background_color?: string; + scope?: string; + description?: string; + icons?: PWAIcon[]; + [key: string]: unknown; +} + +interface ServiceWorkerInfo { + scriptURL: string; + scope: string; + state: string; +} + +interface CacheInfo { + name: string; + itemCount: number; + urls?: string[]; +} + +interface PWAInfo { + isPWA: boolean; + reason?: string; + url: string; + manifest?: PWAManifest; + manifestUrl?: string; + serviceWorker?: ServiceWorkerInfo; + caches?: CacheInfo[]; + errors?: string[]; +} + +interface CacheResource { + url: string; + contentType: string; + content: string; // base64 for binary, text for readable + isBinary: boolean; + size: number; +} + +/** + * Detect and report PWA metadata for the current page. + */ +const pwaInfo = defineTabTool({ + capability: 'core', + + schema: { + name: 'browser_pwa_info', + title: 'Get PWA information', + description: 'Detect and report Progressive Web App (PWA) metadata for the current page including manifest, service worker, and cache information.', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async (tab, _params, response) => { + const page = tab.page; + const pageUrl = page.url(); + + const info: PWAInfo = { + isPWA: false, + url: pageUrl, + errors: [], + }; + + // Check for manifest link + const manifestData = await page.evaluate(async () => { + const result: { + manifestUrl: string | null; + manifest: PWAManifest | null; + error?: string; + } = { + manifestUrl: null, + manifest: null, + }; + + const manifestLink = document.querySelector('link[rel="manifest"]') as HTMLLinkElement | null; + if (!manifestLink?.href) { + result.error = 'No manifest link found'; + return result; + } + + result.manifestUrl = manifestLink.href; + + try { + const resp = await fetch(manifestLink.href); + if (!resp.ok) { + result.error = `Failed to fetch manifest: ${resp.status} ${resp.statusText}`; + return result; + } + result.manifest = await resp.json(); + } catch (e) { + result.error = `Error fetching manifest: ${e}`; + } + + return result; + }); + + if (manifestData.error) + info.errors!.push(manifestData.error); + + if (manifestData.manifest) { + info.manifest = manifestData.manifest; + info.manifestUrl = manifestData.manifestUrl ?? undefined; + } + + // Check for service worker + const swData = await page.evaluate(async () => { + const result: { + serviceWorker: ServiceWorkerInfo | null; + error?: string; + } = { + serviceWorker: null, + }; + + if (!('serviceWorker' in navigator)) { + result.error = 'Service Worker API not supported'; + return result; + } + + try { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration) { + result.error = 'No service worker registered'; + return result; + } + + const sw = registration.active || registration.waiting || registration.installing; + if (!sw) { + result.error = 'No active service worker found'; + return result; + } + + result.serviceWorker = { + scriptURL: sw.scriptURL, + scope: registration.scope, + state: sw.state, + }; + } catch (e) { + result.error = `Error getting service worker: ${e}`; + } + + return result; + }); + + if (swData.error) + info.errors!.push(swData.error); + + if (swData.serviceWorker) + info.serviceWorker = swData.serviceWorker; + + + // Get cache information + const cacheData = await page.evaluate(async () => { + const result: { + caches: CacheInfo[]; + error?: string; + } = { + caches: [], + }; + + if (!('caches' in window)) { + result.error = 'CacheStorage API not supported'; + return result; + } + + try { + const cacheNames = await caches.keys(); + for (const name of cacheNames) { + const cache = await caches.open(name); + const keys = await cache.keys(); + result.caches.push({ + name, + itemCount: keys.length, + urls: keys.map(r => r.url), + }); + } + } catch (e) { + result.error = `Error accessing caches: ${e}`; + } + + return result; + }); + + if (cacheData.error) + info.errors!.push(cacheData.error); + + if (cacheData.caches) + info.caches = cacheData.caches; + + + // Determine if this is a PWA + const hasManifest = !!info.manifest; + const hasServiceWorker = !!info.serviceWorker; + info.isPWA = hasManifest && hasServiceWorker; + + if (!info.isPWA) { + const missing: string[] = []; + if (!hasManifest) + missing.push('manifest'); + if (!hasServiceWorker) + missing.push('service worker'); + info.reason = `Missing: ${missing.join(', ')}`; + } + + // Clean up empty errors array + if (info.errors!.length === 0) + delete info.errors; + + + // Build response + const lines: string[] = []; + lines.push('### PWA Information\n'); + lines.push(`**URL:** ${info.url}`); + lines.push(`**Is PWA:** ${info.isPWA ? 'Yes' : 'No'}${info.reason ? ` (${info.reason})` : ''}`); + + if (info.manifest) { + lines.push('\n#### Manifest'); + lines.push(`- **Name:** ${info.manifest.name || info.manifest.short_name || '(not set)'}`); + if (info.manifest.description) + lines.push(`- **Description:** ${info.manifest.description}`); + + lines.push(`- **Start URL:** ${info.manifest.start_url || '(not set)'}`); + lines.push(`- **Display:** ${info.manifest.display || '(not set)'}`); + lines.push(`- **Theme Color:** ${info.manifest.theme_color || '(not set)'}`); + lines.push(`- **Scope:** ${info.manifest.scope || '(not set)'}`); + if (info.manifest.icons?.length) + lines.push(`- **Icons:** ${info.manifest.icons.length} defined (${info.manifest.icons.map(i => i.sizes || 'unknown').join(', ')})`); + + } + + if (info.serviceWorker) { + lines.push('\n#### Service Worker'); + lines.push(`- **Script:** ${info.serviceWorker.scriptURL}`); + lines.push(`- **Scope:** ${info.serviceWorker.scope}`); + lines.push(`- **State:** ${info.serviceWorker.state}`); + } + + if (info.caches && info.caches.length > 0) { + lines.push('\n#### Caches'); + let totalItems = 0; + for (const cache of info.caches) { + lines.push(`- **${cache.name}:** ${cache.itemCount} items`); + totalItems += cache.itemCount; + } + lines.push(`- **Total:** ${info.caches.length} cache(s), ${totalItems} items`); + } + + if (info.errors && info.errors.length > 0) { + lines.push('\n#### Errors'); + for (const error of info.errors) + lines.push(`- ${error}`); + + } + + response.addResult(lines.join('\n')); + }, +}); + +/** + * Download complete PWA package to directory. + */ +const pwaDownload = defineTabTool({ + capability: 'core', + + schema: { + name: 'browser_pwa_download', + title: 'Download PWA package', + description: 'Download complete Progressive Web App (PWA) package including manifest, icons, service worker, and cached resources.', + inputSchema: z.object({ + outputDir: z.string().optional().describe('Custom output directory path. If not specified, uses default artifact directory.'), + includeIcons: z.boolean().optional().default(true).describe('Download all icon sizes from manifest (default: true)'), + includeCache: z.boolean().optional().default(true).describe('Download cached resources from CacheStorage (default: true)'), + createZip: z.boolean().optional().default(false).describe('Create zip archive of downloaded content (default: false)'), + maxCacheSize: z.number().optional().default(100).describe('Maximum total cache size to download in MB (default: 100)'), + }), + type: 'destructive', + }, + + handle: async (tab, params, response) => { + const page = tab.page; + const pageUrl = page.url(); + const maxCacheSizeBytes = (params.maxCacheSize ?? 100) * 1024 * 1024; + + const errors: string[] = []; + + // First, gather all PWA info + const manifestData = await page.evaluate(async () => { + const result: { + manifestUrl: string | null; + manifest: PWAManifest | null; + baseUrl: string; + error?: string; + } = { + manifestUrl: null, + manifest: null, + baseUrl: location.origin, + }; + + const manifestLink = document.querySelector('link[rel="manifest"]') as HTMLLinkElement | null; + if (!manifestLink?.href) + return result; + + + result.manifestUrl = manifestLink.href; + + try { + const resp = await fetch(manifestLink.href); + if (resp.ok) + result.manifest = await resp.json(); + else + result.error = `Failed to fetch manifest: ${resp.status}`; + + } catch (e) { + result.error = `Error fetching manifest: ${e}`; + } + + return result; + }); + + if (manifestData.error) + errors.push(manifestData.error); + + + // Get service worker info + const swData = await page.evaluate(async () => { + const result: { + scriptURL: string | null; + scriptContent: string | null; + error?: string; + } = { + scriptURL: null, + scriptContent: null, + }; + + if (!('serviceWorker' in navigator)) + return result; + + + try { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration) + return result; + + + const sw = registration.active || registration.waiting || registration.installing; + if (!sw) + return result; + + + result.scriptURL = sw.scriptURL; + + // Try to fetch the service worker script + try { + const resp = await fetch(sw.scriptURL); + if (resp.ok) + result.scriptContent = await resp.text(); + else + result.error = `Failed to fetch SW script: ${resp.status}`; + + } catch (e) { + result.error = `Error fetching SW script: ${e}`; + } + } catch (e) { + result.error = `Error getting service worker: ${e}`; + } + + return result; + }); + + if (swData.error) + errors.push(swData.error); + + + // Determine output directory + let outputBaseDir: string; + const appName = sanitizeForFilePath( + manifestData.manifest?.short_name || + manifestData.manifest?.name || + new URL(pageUrl).hostname + ); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const packageDirName = `pwa-${appName}-${timestamp}`; + + if (params.outputDir) { + outputBaseDir = params.outputDir; + } else { + const registry = ArtifactManagerRegistry.getInstance(); + const artifactManager = tab.context.sessionId ? registry.getManager(tab.context.sessionId) : undefined; + if (artifactManager) { + outputBaseDir = artifactManager.getSubdirectory('pwa'); + } else { + outputBaseDir = await outputFile(tab.context.config, 'pwa'); + await fs.promises.mkdir(outputBaseDir, { recursive: true }); + } + } + + const packageDir = path.join(outputBaseDir, packageDirName); + await fs.promises.mkdir(packageDir, { recursive: true }); + + const downloadedFiles: string[] = []; + + // Save manifest + if (manifestData.manifest) { + const manifestPath = path.join(packageDir, 'manifest.json'); + await fs.promises.writeFile(manifestPath, JSON.stringify(manifestData.manifest, null, 2)); + downloadedFiles.push('manifest.json'); + } + + // Download icons + const iconResults: { src: string; path: string; error?: string }[] = []; + if (params.includeIcons !== false && manifestData.manifest?.icons?.length) { + const iconsDir = path.join(packageDir, 'icons'); + await fs.promises.mkdir(iconsDir, { recursive: true }); + + for (const icon of manifestData.manifest.icons) { + const iconUrl = new URL(icon.src, manifestData.baseUrl).href; + const iconFilename = sanitizeForFilePath(path.basename(new URL(iconUrl).pathname)) || `icon-${icon.sizes || 'unknown'}.png`; + + try { + const iconData = await page.evaluate(async (url: string) => { + try { + const resp = await fetch(url); + if (!resp.ok) + return { error: `HTTP ${resp.status}` }; + + const blob = await resp.blob(); + const reader = new FileReader(); + return new Promise<{ data: string; type: string } | { error: string }>((resolve) => { + reader.onloadend = () => { + const base64 = (reader.result as string).split(',')[1]; + resolve({ data: base64, type: blob.type }); + }; + reader.onerror = () => resolve({ error: 'FileReader error' }); + reader.readAsDataURL(blob); + }); + } catch (e) { + return { error: `${e}` }; + } + }, iconUrl); + + if ('error' in iconData) { + iconResults.push({ src: icon.src, path: '', error: iconData.error }); + errors.push(`Icon ${icon.src}: ${iconData.error}`); + } else { + const iconPath = path.join(iconsDir, iconFilename); + await fs.promises.writeFile(iconPath, Buffer.from(iconData.data, 'base64')); + iconResults.push({ src: icon.src, path: `icons/${iconFilename}` }); + downloadedFiles.push(`icons/${iconFilename}`); + } + } catch (e) { + iconResults.push({ src: icon.src, path: '', error: `${e}` }); + errors.push(`Icon ${icon.src}: ${e}`); + } + } + } + + // Save service worker + if (swData.scriptContent) { + const swDir = path.join(packageDir, 'service-worker'); + await fs.promises.mkdir(swDir, { recursive: true }); + + const swFilename = path.basename(new URL(swData.scriptURL!).pathname) || 'sw.js'; + const swPath = path.join(swDir, swFilename); + await fs.promises.writeFile(swPath, swData.scriptContent); + downloadedFiles.push(`service-worker/${swFilename}`); + } + + // Download cached resources + let totalCacheSize = 0; + let cacheLimitReached = false; + const cacheResults: { name: string; itemCount: number; downloadedCount: number; error?: string }[] = []; + + if (params.includeCache !== false) { + const cacheDir = path.join(packageDir, 'cache'); + + const cacheNames = await page.evaluate(async () => { + if (!('caches' in window)) + return []; + + try { + return await caches.keys(); + } catch { + return []; + } + }); + + for (const cacheName of cacheNames) { + if (cacheLimitReached) + break; + + + const cacheResult: { name: string; itemCount: number; downloadedCount: number; error?: string } = { + name: cacheName, + itemCount: 0, + downloadedCount: 0, + }; + + const sanitizedCacheName = sanitizeForFilePath(cacheName); + const cacheSubDir = path.join(cacheDir, sanitizedCacheName); + await fs.promises.mkdir(cacheSubDir, { recursive: true }); + + // Get all URLs in this cache + const cacheUrls = await page.evaluate(async (name: string) => { + try { + const cache = await caches.open(name); + const requests = await cache.keys(); + return requests.map(r => r.url); + } catch { + return []; + } + }, cacheName); + + cacheResult.itemCount = cacheUrls.length; + + for (const url of cacheUrls) { + if (cacheLimitReached) + break; + + + // Fetch from cache and download + const resourceData = await page.evaluate(async (args: { cacheName: string; url: string }) => { + try { + const cache = await caches.open(args.cacheName); + const response = await cache.match(args.url); + if (!response) + return { error: 'Not found in cache' }; + + + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const isBinary = !contentType.startsWith('text/') && + !contentType.includes('json') && + !contentType.includes('xml') && + !contentType.includes('javascript'); + + if (isBinary) { + const blob = await response.blob(); + const reader = new FileReader(); + return new Promise((resolve) => { + reader.onloadend = () => { + const base64 = (reader.result as string).split(',')[1] || ''; + resolve({ + url: args.url, + contentType, + content: base64, + isBinary: true, + size: blob.size, + }); + }; + reader.onerror = () => resolve({ error: 'FileReader error' }); + reader.readAsDataURL(blob); + }); + } else { + const text = await response.text(); + return { + url: args.url, + contentType, + content: text, + isBinary: false, + size: new Blob([text]).size, + }; + } + } catch (e) { + return { error: `${e}` }; + } + }, { cacheName, url }); + + if ('error' in resourceData) { + errors.push(`Cache resource ${url}: ${resourceData.error}`); + continue; + } + + // Check size limit + if (totalCacheSize + resourceData.size > maxCacheSizeBytes) { + cacheLimitReached = true; + errors.push(`Cache size limit (${params.maxCacheSize}MB) reached, stopping cache download`); + break; + } + + // Generate filename from URL + const parsedUrl = new URL(url); + let resourcePath = parsedUrl.pathname; + if (resourcePath.endsWith('/')) + resourcePath += 'index.html'; + + if (!path.extname(resourcePath)) { + // Guess extension from content type + const extMap: Record = { + 'text/html': '.html', + 'text/css': '.css', + 'application/javascript': '.js', + 'text/javascript': '.js', + 'application/json': '.json', + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/svg+xml': '.svg', + 'image/webp': '.webp', + }; + const ext = Object.entries(extMap).find(([type]) => resourceData.contentType.includes(type))?.[1] || ''; + resourcePath += ext; + } + + // Sanitize the path + const sanitizedPath = resourcePath.split('/').filter(Boolean).map(sanitizeForFilePath).join(path.sep); + const fullPath = path.join(cacheSubDir, sanitizedPath); + + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + + if (resourceData.isBinary) { + await fs.promises.writeFile(fullPath, Buffer.from(resourceData.content, 'base64')); + } else { + await fs.promises.writeFile(fullPath, resourceData.content); + } + + totalCacheSize += resourceData.size; + cacheResult.downloadedCount++; + } + + cacheResults.push(cacheResult); + downloadedFiles.push(`cache/${sanitizedCacheName}/ (${cacheResult.downloadedCount} files)`); + } + } + + // Create pwa-info.json with metadata about the download + const pwaInfoMetadata = { + downloadedAt: new Date().toISOString(), + sourceUrl: pageUrl, + manifest: manifestData.manifest, + manifestUrl: manifestData.manifestUrl, + serviceWorker: swData.scriptURL ? { + scriptURL: swData.scriptURL, + downloaded: !!swData.scriptContent, + } : null, + icons: iconResults, + caches: cacheResults, + totalCacheSize, + cacheLimitReached, + errors: errors.length > 0 ? errors : undefined, + }; + + await fs.promises.writeFile( + path.join(packageDir, 'pwa-info.json'), + JSON.stringify(pwaInfoMetadata, null, 2) + ); + downloadedFiles.push('pwa-info.json'); + + // Create zip if requested + let zipPath: string | undefined; + if (params.createZip) { + try { + // Use native zip command if available + const { execSync } = await import('child_process'); + zipPath = `${packageDir}.zip`; + execSync(`cd "${outputBaseDir}" && zip -r "${packageDirName}.zip" "${packageDirName}"`, { + encoding: 'utf8', + stdio: 'pipe', + }); + downloadedFiles.push(`${packageDirName}.zip`); + } catch (e) { + errors.push(`Failed to create zip: ${e}`); + } + } + + // Build response + const lines: string[] = []; + lines.push('### PWA Download Complete\n'); + lines.push(`**Source:** ${pageUrl}`); + lines.push(`**Package:** ${packageDir}`); + if (zipPath) + lines.push(`**Zip:** ${zipPath}`); + + + lines.push('\n#### Downloaded Files'); + for (const file of downloadedFiles) + lines.push(`- ${file}`); + + + if (manifestData.manifest) { + lines.push('\n#### Manifest'); + lines.push(`- **Name:** ${manifestData.manifest.name || manifestData.manifest.short_name || '(not set)'}`); + lines.push(`- **Icons:** ${iconResults.filter(i => !i.error).length}/${manifestData.manifest.icons?.length || 0} downloaded`); + } + + if (swData.scriptContent) { + lines.push('\n#### Service Worker'); + lines.push(`- **Script:** ${swData.scriptURL}`); + } + + if (cacheResults.length > 0) { + lines.push('\n#### Caches'); + for (const cache of cacheResults) + lines.push(`- **${cache.name}:** ${cache.downloadedCount}/${cache.itemCount} items downloaded`); + + lines.push(`- **Total size:** ${(totalCacheSize / 1024 / 1024).toFixed(2)} MB`); + if (cacheLimitReached) + lines.push(`- **Note:** Cache size limit reached, download incomplete`); + + } + + if (errors.length > 0) { + lines.push('\n#### Errors'); + for (const error of errors.slice(0, 10)) + lines.push(`- ${error}`); + + if (errors.length > 10) + lines.push(`- ... and ${errors.length - 10} more errors (see pwa-info.json)`); + + } + + response.addResult(lines.join('\n')); + }, +}); + +export default [ + pwaInfo, + pwaDownload, +]; diff --git a/tests/capabilities.spec.ts b/tests/capabilities.spec.ts index 70d0a90..07ae93b 100644 --- a/tests/capabilities.spec.ts +++ b/tests/capabilities.spec.ts @@ -75,6 +75,8 @@ test('test snapshot tool list', async ({ client }) => { 'browser_network_requests', 'browser_pause_recording', 'browser_press_key', + 'browser_pwa_download', + 'browser_pwa_info', 'browser_recording_status', 'browser_request_monitoring_status', 'browser_resize', diff --git a/tests/pwa.spec.ts b/tests/pwa.spec.ts new file mode 100644 index 0000000..7fe1cb4 --- /dev/null +++ b/tests/pwa.spec.ts @@ -0,0 +1,364 @@ +/** + * 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 fs from 'fs'; +import path from 'path'; +import { test, expect } from './fixtures.js'; + +// Helper to setup PWA routes on test server +function setupPWARoutes(server: any) { + const manifest = { + name: 'Test PWA App', + short_name: 'TestPWA', + description: 'A test PWA for testing purposes', + start_url: '/', + display: 'standalone', + theme_color: '#ffffff', + background_color: '#ffffff', + scope: '/', + icons: [ + { + src: '/icon-192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/icon-512.png', + sizes: '512x512', + type: 'image/png', + }, + ], + }; + + // PWA HTML page with manifest link + server.setContent('/pwa', ` + + + Test PWA + + + +

Test PWA Application

+ + + + `, 'text/html'); + + // Manifest + server.route('/manifest.json', (req: any, res: any) => { + res.writeHead(200, { 'Content-Type': 'application/manifest+json' }); + res.end(JSON.stringify(manifest)); + }); + + // Service worker + server.route('/sw.js', (req: any, res: any) => { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(` + const CACHE_NAME = 'test-cache-v1'; + const urlsToCache = ['/']; + + self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(urlsToCache)) + ); + }); + + self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request).then(response => response || fetch(event.request)) + ); + }); + `); + }); + + // Simple PNG icons (1x1 pixel PNGs) + const pngHeader = Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit depth, color type + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xFF, 0xFF, 0x3F, + 0x00, 0x05, 0xFE, 0x02, 0xFE, 0xDC, 0xCC, 0x59, + 0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, // IEND chunk + 0x44, 0xAE, 0x42, 0x60, 0x82, + ]); + + server.route('/icon-192.png', (req: any, res: any) => { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(pngHeader); + }); + + server.route('/icon-512.png', (req: any, res: any) => { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(pngHeader); + }); + + return { manifest }; +} + +// Setup non-PWA page (no manifest) +function setupNonPWARoutes(server: any) { + server.setContent('/non-pwa', ` + + Non PWA Page +

This is not a PWA

+ + `, 'text/html'); +} + +test('browser_pwa_info - detects PWA with manifest', async ({ startClient, server }) => { + setupPWARoutes(server); + + const { client } = await startClient(); + + // Navigate to PWA page + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}pwa` }, + }); + + // Wait a moment for service worker registration + await client.callTool({ + name: 'browser_wait_for', + arguments: { time: 1 }, + }); + + // Get PWA info + const result = await client.callTool({ + name: 'browser_pwa_info', + arguments: {}, + }); + + expect(result).toContainTextContent('### PWA Information'); + expect(result).toContainTextContent('Test PWA App'); + expect(result).toContainTextContent('#### Manifest'); +}); + +test('browser_pwa_info - detects non-PWA page', async ({ startClient, server }) => { + setupNonPWARoutes(server); + + const { client } = await startClient(); + + // Navigate to non-PWA page + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}non-pwa` }, + }); + + // Get PWA info + const result = await client.callTool({ + name: 'browser_pwa_info', + arguments: {}, + }); + + expect(result).toContainTextContent('### PWA Information'); + expect(result).toContainTextContent('**Is PWA:** No'); + expect(result).toContainTextContent('Missing: manifest'); +}); + +test('browser_pwa_info - reports manifest details', async ({ startClient, server }) => { + const { manifest } = setupPWARoutes(server); + + const { client } = await startClient(); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}pwa` }, + }); + + const result = await client.callTool({ + name: 'browser_pwa_info', + arguments: {}, + }); + + expect(result).toContainTextContent(`**Name:** ${manifest.name}`); + expect(result).toContainTextContent(`**Start URL:** ${manifest.start_url}`); + expect(result).toContainTextContent(`**Display:** ${manifest.display}`); + expect(result).toContainTextContent(`**Theme Color:** ${manifest.theme_color}`); + expect(result).toContainTextContent('**Icons:** 2 defined'); +}); + +test('browser_pwa_download - downloads manifest and icons', async ({ startClient, server }, testInfo) => { + setupPWARoutes(server); + const outputDir = testInfo.outputPath('pwa-output'); + + const { client } = await startClient({ + config: { outputDir }, + }); + + // Navigate to PWA page + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}pwa` }, + }); + + // Wait for SW registration + await client.callTool({ + name: 'browser_wait_for', + arguments: { time: 1 }, + }); + + // Download PWA + const result = await client.callTool({ + name: 'browser_pwa_download', + arguments: { + outputDir, + includeIcons: true, + includeCache: false, // Skip cache for faster test + }, + }); + + expect(result).toContainTextContent('### PWA Download Complete'); + expect(result).toContainTextContent('manifest.json'); + expect(result).toContainTextContent('pwa-info.json'); + + // Verify files were created + const dirs = await fs.promises.readdir(outputDir); + expect(dirs.length).toBeGreaterThan(0); + + // Find the PWA package directory + const pwaDirs = dirs.filter(d => d.startsWith('pwa-')); + expect(pwaDirs.length).toBe(1); + + const packageDir = path.join(outputDir, pwaDirs[0]); + const files = await fs.promises.readdir(packageDir); + + expect(files).toContain('manifest.json'); + expect(files).toContain('pwa-info.json'); + + // Verify manifest content + const manifestContent = JSON.parse( + await fs.promises.readFile(path.join(packageDir, 'manifest.json'), 'utf-8') + ); + expect(manifestContent.name).toBe('Test PWA App'); + expect(manifestContent.short_name).toBe('TestPWA'); +}); + +test('browser_pwa_download - creates correct directory structure', async ({ startClient, server }, testInfo) => { + setupPWARoutes(server); + const outputDir = testInfo.outputPath('pwa-structure'); + + const { client } = await startClient({ + config: { outputDir }, + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}pwa` }, + }); + + await client.callTool({ + name: 'browser_wait_for', + arguments: { time: 1 }, + }); + + await client.callTool({ + name: 'browser_pwa_download', + arguments: { + outputDir, + includeIcons: true, + includeCache: false, + }, + }); + + // Find package directory + const dirs = await fs.promises.readdir(outputDir); + const pwaDirs = dirs.filter(d => d.startsWith('pwa-')); + const packageDir = path.join(outputDir, pwaDirs[0]); + + // Check icons directory + const iconsDir = path.join(packageDir, 'icons'); + if (fs.existsSync(iconsDir)) { + const iconFiles = await fs.promises.readdir(iconsDir); + // Should have icon files + expect(iconFiles.length).toBeGreaterThanOrEqual(0); + } + + // Verify pwa-info.json metadata + const pwaInfo = JSON.parse( + await fs.promises.readFile(path.join(packageDir, 'pwa-info.json'), 'utf-8') + ); + expect(pwaInfo).toHaveProperty('downloadedAt'); + expect(pwaInfo).toHaveProperty('sourceUrl'); + expect(pwaInfo).toHaveProperty('manifest'); + expect(pwaInfo.manifest.name).toBe('Test PWA App'); +}); + +test('browser_pwa_download - handles missing manifest gracefully', async ({ startClient, server }, testInfo) => { + setupNonPWARoutes(server); + const outputDir = testInfo.outputPath('pwa-no-manifest'); + + const { client } = await startClient({ + config: { outputDir }, + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}non-pwa` }, + }); + + const result = await client.callTool({ + name: 'browser_pwa_download', + arguments: { + outputDir, + }, + }); + + // Should still complete without error + expect(result).toContainTextContent('### PWA Download Complete'); + expect(result).toContainTextContent('pwa-info.json'); +}); + +test('browser_pwa_download - respects includeIcons=false', async ({ startClient, server }, testInfo) => { + setupPWARoutes(server); + const outputDir = testInfo.outputPath('pwa-no-icons'); + + const { client } = await startClient({ + config: { outputDir }, + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: `${server.PREFIX}pwa` }, + }); + + await client.callTool({ + name: 'browser_pwa_download', + arguments: { + outputDir, + includeIcons: false, + includeCache: false, + }, + }); + + // Find package directory + const dirs = await fs.promises.readdir(outputDir); + const pwaDirs = dirs.filter(d => d.startsWith('pwa-')); + const packageDir = path.join(outputDir, pwaDirs[0]); + + // Icons directory should not exist or be empty + const iconsDir = path.join(packageDir, 'icons'); + expect(fs.existsSync(iconsDir)).toBe(false); +});