feat: add PWA inspection and download tools

Add browser_pwa_info (read-only) to detect and report PWA metadata
including manifest, service worker state, and CacheStorage contents.

Add browser_pwa_download to save complete PWA packages with manifest,
icons, service worker scripts, and cached resources to disk.
This commit is contained in:
Ryan Malloy 2026-02-02 19:43:11 -07:00
parent f8e3abac82
commit 8e0953abc4
5 changed files with 1295 additions and 0 deletions

174
README.md
View File

@ -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) => {
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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) => {
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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!
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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!
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
<!-- NOTE: This has been generated via update-readme.js -->
- **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.

View File

@ -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<any>[] = [
...notifications,
...mouse,
...pdf,
...pwa,
...requests,
...screenshot,
...sensors,

753
src/tools/pwa.ts Normal file
View File

@ -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<CacheResource | { error: string }>((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<string, string> = {
'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,
];

View File

@ -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',

364
tests/pwa.spec.ts Normal file
View File

@ -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', `
<html>
<head>
<title>Test PWA</title>
<link rel="manifest" href="/manifest.json">
</head>
<body>
<h1>Test PWA Application</h1>
<script>
// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => console.log('SW registered'))
.catch(err => console.log('SW failed:', err));
}
</script>
</body>
</html>
`, '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', `
<html>
<head><title>Non PWA Page</title></head>
<body><h1>This is not a PWA</h1></body>
</html>
`, '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);
});