Compare commits
10 Commits
74598164a1
...
4bb1b26137
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bb1b26137 | |||
| 6ae3991efb | |||
| 6cbc5f6bc1 | |||
| a65f00667a | |||
| 8e0953abc4 | |||
| f8e3abac82 | |||
| 21704ef0b5 | |||
| d15521c474 | |||
| fd76dae90b | |||
| eb8e25ddc6 |
174
README.md
174
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) => {
|
||||
|
||||
<!-- 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.
|
||||
|
||||
@ -22,6 +22,7 @@ import os from 'node:os';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { logUnhandledError, testDebug } from './log.js';
|
||||
import { installBrowser, isMissingBrowserError } from './browserInstaller.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
@ -136,11 +137,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
||||
];
|
||||
}
|
||||
|
||||
return browserType.launch(launchOptions).catch(error => {
|
||||
if (error.message.includes('Executable doesn\'t exist'))
|
||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||
throw error;
|
||||
});
|
||||
return launchWithAutoInstall(this.browserConfig, launchOptions, browserType);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise<playwright.BrowserContext> {
|
||||
@ -217,14 +214,19 @@ class PersistentContextFactory implements BrowserContextFactory {
|
||||
}
|
||||
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
let didAutoInstall = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions);
|
||||
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||
return { browserContext, close };
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('Executable doesn\'t exist'))
|
||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||
if (!didAutoInstall && isMissingBrowserError(error)) {
|
||||
testDebug('browser missing, attempting auto-install (persistent)');
|
||||
didAutoInstall = true;
|
||||
await installBrowser(this.browserConfig);
|
||||
continue;
|
||||
}
|
||||
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||
// User data directory is already in use, try again.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
@ -275,3 +277,24 @@ async function findFreePort(): Promise<number> {
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the given browser, auto-installing it if the executable is missing.
|
||||
* Only retries once — if install succeeds and launch still fails, we surface
|
||||
* the original error to the caller.
|
||||
*/
|
||||
export async function launchWithAutoInstall(
|
||||
browserConfig: FullConfig['browser'],
|
||||
launchOptions: playwright.LaunchOptions,
|
||||
browserType: playwright.BrowserType,
|
||||
): Promise<playwright.Browser> {
|
||||
try {
|
||||
return await browserType.launch(launchOptions);
|
||||
} catch (error) {
|
||||
if (!isMissingBrowserError(error))
|
||||
throw error;
|
||||
testDebug('browser missing, attempting auto-install (isolated)');
|
||||
await installBrowser(browserConfig);
|
||||
return browserType.launch(launchOptions);
|
||||
}
|
||||
}
|
||||
|
||||
159
src/browserInstaller.ts
Normal file
159
src/browserInstaller.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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 { fork, spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { testDebug } from './log.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
// Cache installs per-process so repeated launches don't re-run the installer.
|
||||
const installedBrowsers = new Set<string>();
|
||||
const inflightInstalls = new Map<string, Promise<void>>();
|
||||
|
||||
function browserTarget(browserConfig: FullConfig['browser']): string {
|
||||
return browserConfig.launchOptions?.channel
|
||||
?? browserConfig.browserName
|
||||
?? 'chrome';
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `playwright install <target>` in a forked child process.
|
||||
* Safe to call concurrently — in-flight installs are deduplicated per target.
|
||||
*/
|
||||
export async function installBrowser(browserConfig: FullConfig['browser']): Promise<void> {
|
||||
const target = browserTarget(browserConfig);
|
||||
|
||||
if (installedBrowsers.has(target))
|
||||
return;
|
||||
|
||||
const existing = inflightInstalls.get(target);
|
||||
if (existing)
|
||||
return existing;
|
||||
|
||||
const promise = runInstall(target).then(() => {
|
||||
installedBrowsers.add(target);
|
||||
}).finally(() => {
|
||||
inflightInstalls.delete(target);
|
||||
});
|
||||
|
||||
inflightInstalls.set(target, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function runInstall(target: string): Promise<void> {
|
||||
testDebug(`auto-installing browser: ${target}`);
|
||||
const cliPath = resolvePlaywrightCli();
|
||||
|
||||
await runPlaywrightCli(cliPath, ['install', target]);
|
||||
|
||||
// macOS: strip Gatekeeper quarantine attribute from freshly downloaded
|
||||
// browser binaries. Without this, the first launch is silently blocked
|
||||
// by macOS on a fresh install.
|
||||
if (process.platform === 'darwin')
|
||||
await stripDarwinQuarantine();
|
||||
|
||||
// Best-effort system-deps install. Only runs when we're already root,
|
||||
// otherwise skipped silently — users will see Playwright's own missing-lib
|
||||
// error on the next launch, which tells them exactly what to apt install.
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
try {
|
||||
await runPlaywrightCli(cliPath, ['install-deps', target]);
|
||||
} catch (e) {
|
||||
testDebug(`install-deps failed (non-fatal): ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the `com.apple.quarantine` extended attribute from Playwright's
|
||||
* browser cache directory. macOS sets this on anything downloaded from the
|
||||
* network; on Tahoe and later it causes Gatekeeper to silently block launch.
|
||||
*
|
||||
* Best-effort: errors are logged but never thrown. If `xattr` doesn't exist,
|
||||
* the cache dir is missing, or the attribute isn't present, we just move on.
|
||||
*/
|
||||
async function stripDarwinQuarantine(): Promise<void> {
|
||||
const cacheDir = process.env.PLAYWRIGHT_BROWSERS_PATH
|
||||
|| path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright');
|
||||
|
||||
// PLAYWRIGHT_BROWSERS_PATH=0 means "install into node_modules" — skip,
|
||||
// since those binaries weren't downloaded with quarantine in that flow.
|
||||
if (cacheDir === '0')
|
||||
return;
|
||||
|
||||
testDebug(`stripping quarantine attribute from ${cacheDir}`);
|
||||
await new Promise<void>(resolve => {
|
||||
const child = spawn('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', cacheDir], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
child.on('close', code => {
|
||||
if (code !== 0)
|
||||
testDebug(`xattr exited ${code} (non-fatal)`);
|
||||
resolve();
|
||||
});
|
||||
child.on('error', err => {
|
||||
testDebug(`xattr spawn failed (non-fatal): ${err.message}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlaywrightCli(): string {
|
||||
try {
|
||||
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||
return path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'Playwright package not found. Install it with: npm install playwright\n' +
|
||||
`Original error: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runPlaywrightCli(cliPath: string, args: string[]): Promise<void> {
|
||||
const child = fork(cliPath, args, { stdio: 'pipe' });
|
||||
const output: string[] = [];
|
||||
child.stdout?.on('data', data => output.push(data.toString()));
|
||||
child.stderr?.on('data', data => output.push(data.toString()));
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`playwright ${args.join(' ')} failed (exit ${code}):\n${output.join('')}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given error indicates the browser executable is missing
|
||||
* and needs to be downloaded via `playwright install`.
|
||||
*/
|
||||
export function isMissingBrowserError(error: unknown): boolean {
|
||||
if (!(error instanceof Error))
|
||||
return false;
|
||||
const msg = error.message;
|
||||
return msg.includes("Executable doesn't exist")
|
||||
|| msg.includes('please run the following command')
|
||||
|| msg.includes('npx playwright install');
|
||||
}
|
||||
@ -23,12 +23,13 @@ import { Tab } from './tab.js';
|
||||
import { EnvironmentIntrospector } from './environmentIntrospection.js';
|
||||
import { RequestInterceptor, RequestInterceptorOptions } from './requestInterceptor.js';
|
||||
import { ArtifactManagerRegistry } from './artifactManager.js';
|
||||
import { launchWithAutoInstall } from './browserContextFactory.js';
|
||||
import { PlaywrightRipgrepEngine } from './filtering/engine.js';
|
||||
|
||||
import type { Tool, WebNotification, RTCConnectionData } from './tools/tool.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
import type { InjectionConfig } from './tools/codeInjection.js';
|
||||
import { PlaywrightRipgrepEngine } from './filtering/engine.js';
|
||||
import type { DifferentialFilterParams } from './filtering/models.js';
|
||||
|
||||
// Virtual Accessibility Tree for React-style reconciliation
|
||||
@ -466,7 +467,7 @@ export class Context {
|
||||
];
|
||||
}
|
||||
|
||||
const browser = await browserType.launch(launchOptions);
|
||||
const browser = await launchWithAutoInstall(this.config.browser, launchOptions, browserType);
|
||||
|
||||
// Use environment-specific video directory if available
|
||||
const videoConfig = envOptions.recordVideo ?
|
||||
|
||||
31
src/tab.ts
31
src/tab.ts
@ -30,6 +30,33 @@ type PageEx = playwright.Page & {
|
||||
_snapshotForAI: () => Promise<string>;
|
||||
};
|
||||
|
||||
const SNAPSHOT_TIMEOUT_MS = 10000; // 10 seconds
|
||||
|
||||
async function snapshotWithTimeout(page: playwright.Page): Promise<string> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const timeoutPromise = new Promise<string>((resolve) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
resolve(
|
||||
`[Snapshot timed out after ${SNAPSHOT_TIMEOUT_MS / 1000} seconds]\n` +
|
||||
`This can happen with complex pages, SVG files, or file:// URLs.\n` +
|
||||
`Use browser_take_screenshot to view the page, or disable auto-snapshots with browser_configure_snapshots.`
|
||||
);
|
||||
}, SNAPSHOT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
(page as PageEx)._snapshotForAI(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return result;
|
||||
} finally {
|
||||
if (timeoutId)
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export const TabEvents = {
|
||||
modalState: 'modalState'
|
||||
};
|
||||
@ -914,7 +941,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
||||
result.push(...this._listDownloadsMarkdown());
|
||||
|
||||
await this._raceAgainstModalStates(async () => {
|
||||
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||
const snapshot = await snapshotWithTimeout(this.page);
|
||||
result.push(
|
||||
`### Page state`,
|
||||
`- Page URL: ${this.page.url()}`,
|
||||
@ -958,7 +985,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
||||
}
|
||||
|
||||
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
|
||||
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||
const snapshot = await snapshotWithTimeout(this.page);
|
||||
return params.map(param => {
|
||||
if (!snapshot.includes(`[ref=${param.ref}]`))
|
||||
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
|
||||
|
||||
@ -26,11 +26,14 @@ 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 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';
|
||||
import storage from './tools/storage.js';
|
||||
import tabs from './tools/tabs.js';
|
||||
import screenshot from './tools/screenshot.js';
|
||||
import themeManagement from './tools/themeManagement.js';
|
||||
@ -55,13 +58,16 @@ export const allTools: Tool<any>[] = [
|
||||
...keyboard,
|
||||
...navigate,
|
||||
...network,
|
||||
...networkThrottle,
|
||||
...notifications,
|
||||
...mouse,
|
||||
...pdf,
|
||||
...pwa,
|
||||
...requests,
|
||||
...screenshot,
|
||||
...sensors,
|
||||
...snapshot,
|
||||
...storage,
|
||||
...tabs,
|
||||
...themeManagement,
|
||||
...video,
|
||||
|
||||
@ -565,7 +565,7 @@ const enableDebugToolbarSchema = z.object({
|
||||
theme: z.enum(['light', 'dark', 'transparent']).optional().describe('Visual theme: light (white), dark (gray), transparent (glass effect)'),
|
||||
minimized: z.boolean().optional().describe('Start in compact pill mode (default: false)'),
|
||||
showDetails: z.boolean().optional().describe('Show session details when expanded (default: true)'),
|
||||
opacity: z.number().min(0.1).max(1.0).optional().describe('Toolbar opacity 0.1-1.0 (default: 0.95)')
|
||||
opacity: z.coerce.number().min(0.1).max(1.0).optional().describe('Toolbar opacity 0.1-1.0 (default: 0.95)')
|
||||
});
|
||||
|
||||
const injectCustomCodeSchema = z.object({
|
||||
@ -580,13 +580,13 @@ const enableVoiceCollaborationSchema = z.object({
|
||||
enabled: z.boolean().optional().describe('Enable voice collaboration features (default: true)'),
|
||||
autoInitialize: z.boolean().optional().describe('Automatically initialize voice on page load (default: true)'),
|
||||
voiceOptions: z.object({
|
||||
rate: z.number().min(0.1).max(10).optional().describe('Speech rate (0.1-10, default: 1.0)'),
|
||||
pitch: z.number().min(0).max(2).optional().describe('Speech pitch (0-2, default: 1.0)'),
|
||||
volume: z.number().min(0).max(1).optional().describe('Speech volume (0-1, default: 1.0)'),
|
||||
rate: z.coerce.number().min(0.1).max(10).optional().describe('Speech rate (0.1-10, default: 1.0)'),
|
||||
pitch: z.coerce.number().min(0).max(2).optional().describe('Speech pitch (0-2, default: 1.0)'),
|
||||
volume: z.coerce.number().min(0).max(1).optional().describe('Speech volume (0-1, default: 1.0)'),
|
||||
lang: z.string().optional().describe('Language code (default: en-US)')
|
||||
}).optional().describe('Voice synthesis options'),
|
||||
listenOptions: z.object({
|
||||
timeout: z.number().min(1000).max(60000).optional().describe('Voice input timeout in milliseconds (default: 10000)'),
|
||||
timeout: z.coerce.number().min(1000).max(60000).optional().describe('Voice input timeout in milliseconds (default: 10000)'),
|
||||
lang: z.string().optional().describe('Speech recognition language (default: en-US)'),
|
||||
continuous: z.boolean().optional().describe('Keep listening after first result (default: false)')
|
||||
}).optional().describe('Voice recognition options')
|
||||
|
||||
@ -40,10 +40,10 @@ const resize = defineTabTool({
|
||||
schema: {
|
||||
name: 'browser_resize',
|
||||
title: 'Resize browser window',
|
||||
description: 'Resize the browser window',
|
||||
description: 'Resize the browser viewport to the specified width and height in pixels. Common sizes: 1920x1080 (Full HD), 1440x900 (laptop), 1280x720 (HD), 390x844 (mobile).',
|
||||
inputSchema: z.object({
|
||||
width: z.number().describe('Width of the browser window'),
|
||||
height: z.number().describe('Height of the browser window'),
|
||||
width: z.coerce.number().describe('Viewport width in pixels'),
|
||||
height: z.coerce.number().describe('Viewport height in pixels'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
@ -24,15 +24,15 @@ import type { Response } from '../response.js';
|
||||
const configureSchema = z.object({
|
||||
headless: z.boolean().optional().describe('Whether to run the browser in headless mode'),
|
||||
viewport: z.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
width: z.coerce.number(),
|
||||
height: z.coerce.number(),
|
||||
}).optional().describe('Browser viewport size'),
|
||||
userAgent: z.string().optional().describe('User agent string for the browser'),
|
||||
device: z.string().optional().describe('Device to emulate (e.g., "iPhone 13", "iPad", "Pixel 5"). Use browser_list_devices to see available devices.'),
|
||||
geolocation: z.object({
|
||||
latitude: z.number().min(-90).max(90),
|
||||
longitude: z.number().min(-180).max(180),
|
||||
accuracy: z.number().min(0).optional().describe('Accuracy in meters (default: 100)')
|
||||
latitude: z.coerce.number().min(-90).max(90),
|
||||
longitude: z.coerce.number().min(-180).max(180),
|
||||
accuracy: z.coerce.number().min(0).optional().describe('Accuracy in meters (default: 100)')
|
||||
}).optional().describe('Set geolocation coordinates'),
|
||||
locale: z.string().optional().describe('Browser locale (e.g., "en-US", "fr-FR", "ja-JP")'),
|
||||
timezone: z.string().optional().describe('Timezone ID (e.g., "America/New_York", "Europe/London", "Asia/Tokyo")'),
|
||||
@ -46,7 +46,7 @@ const configureSchema = z.object({
|
||||
|
||||
// Browser UI Customization Options
|
||||
chromiumSandbox: z.boolean().optional().describe('Enable/disable Chromium sandbox (affects browser appearance)'),
|
||||
slowMo: z.number().min(0).optional().describe('Slow down operations by specified milliseconds (helps with visual tracking)'),
|
||||
slowMo: z.coerce.number().min(0).optional().describe('Slow down operations by specified milliseconds (helps with visual tracking)'),
|
||||
devtools: z.boolean().optional().describe('Open browser with DevTools panel open (Chromium only)'),
|
||||
args: z.array(z.string()).optional().describe('Additional browser launch arguments for UI customization (e.g., ["--force-color-profile=srgb", "--disable-features=VizDisplayCompositor"])'),
|
||||
});
|
||||
@ -93,7 +93,7 @@ const installPopularExtensionSchema = z.object({
|
||||
|
||||
const configureSnapshotsSchema = z.object({
|
||||
includeSnapshots: z.boolean().optional().describe('Enable/disable automatic snapshots after interactive operations. When false, use browser_snapshot for explicit snapshots.'),
|
||||
maxSnapshotTokens: z.number().min(0).optional().describe('Maximum tokens allowed in snapshots before truncation. Use 0 to disable truncation.'),
|
||||
maxSnapshotTokens: z.coerce.number().min(0).optional().describe('Maximum tokens allowed in snapshots before truncation. Use 0 to disable truncation.'),
|
||||
differentialSnapshots: z.boolean().optional().describe('Enable differential snapshots that show only changes since last snapshot instead of full page snapshots.'),
|
||||
differentialMode: z.enum(['semantic', 'simple', 'both']).optional().describe('Type of differential analysis: "semantic" (React-style reconciliation), "simple" (text diff), or "both" (show comparison).'),
|
||||
consoleOutputFile: z.string().optional().describe('File path to write browser console output to. Set to empty string to disable console file output.'),
|
||||
@ -104,9 +104,9 @@ const configureSnapshotsSchema = z.object({
|
||||
filterMode: z.enum(['content', 'count', 'files']).optional().describe('Type of filtering output: "content" (filtered data), "count" (match statistics), "files" (matching items only)'),
|
||||
caseSensitive: z.boolean().optional().describe('Case sensitive pattern matching (default: true)'),
|
||||
wholeWords: z.boolean().optional().describe('Match whole words only (default: false)'),
|
||||
contextLines: z.number().min(0).optional().describe('Number of context lines around matches'),
|
||||
contextLines: z.coerce.number().min(0).optional().describe('Number of context lines around matches'),
|
||||
invertMatch: z.boolean().optional().describe('Invert match to show non-matches (default: false)'),
|
||||
maxMatches: z.number().min(1).optional().describe('Maximum number of matches to return'),
|
||||
maxMatches: z.coerce.number().min(1).optional().describe('Maximum number of matches to return'),
|
||||
|
||||
// jq Structural Filtering Parameters
|
||||
jqExpression: z.string().optional().describe(
|
||||
|
||||
@ -22,13 +22,13 @@ const elementSchema = z.object({
|
||||
});
|
||||
|
||||
const coordinateSchema = z.object({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
x: z.coerce.number().describe('X coordinate'),
|
||||
y: z.coerce.number().describe('Y coordinate'),
|
||||
});
|
||||
|
||||
const advancedCoordinateSchema = coordinateSchema.extend({
|
||||
precision: z.enum(['pixel', 'subpixel']).optional().default('pixel').describe('Coordinate precision level'),
|
||||
delay: z.number().min(0).max(5000).optional().describe('Delay in milliseconds before action'),
|
||||
delay: z.coerce.number().min(0).max(5000).optional().describe('Delay in milliseconds before action'),
|
||||
});
|
||||
|
||||
const mouseMove = defineTabTool({
|
||||
@ -64,8 +64,8 @@ const mouseClick = defineTabTool({
|
||||
description: 'Click mouse button at a given position with advanced options',
|
||||
inputSchema: elementSchema.extend(advancedCoordinateSchema.shape).extend({
|
||||
button: z.enum(['left', 'right', 'middle']).optional().default('left').describe('Mouse button to click'),
|
||||
clickCount: z.number().min(1).max(3).optional().default(1).describe('Number of clicks (1=single, 2=double, 3=triple)'),
|
||||
holdTime: z.number().min(0).max(2000).optional().default(0).describe('How long to hold button down in milliseconds'),
|
||||
clickCount: z.coerce.number().min(1).max(3).optional().default(1).describe('Number of clicks (1=single, 2=double, 3=triple)'),
|
||||
holdTime: z.coerce.number().min(0).max(2000).optional().default(0).describe('How long to hold button down in milliseconds'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
@ -111,16 +111,16 @@ const mouseDrag = defineTabTool({
|
||||
title: 'Drag mouse',
|
||||
description: 'Drag mouse button from start to end position with advanced drag patterns',
|
||||
inputSchema: elementSchema.extend({
|
||||
startX: z.number().describe('Start X coordinate'),
|
||||
startY: z.number().describe('Start Y coordinate'),
|
||||
endX: z.number().describe('End X coordinate'),
|
||||
endY: z.number().describe('End Y coordinate'),
|
||||
startX: z.coerce.number().describe('Start X coordinate'),
|
||||
startY: z.coerce.number().describe('Start Y coordinate'),
|
||||
endX: z.coerce.number().describe('End X coordinate'),
|
||||
endY: z.coerce.number().describe('End Y coordinate'),
|
||||
button: z.enum(['left', 'right', 'middle']).optional().default('left').describe('Mouse button to drag with'),
|
||||
precision: z.enum(['pixel', 'subpixel']).optional().default('pixel').describe('Coordinate precision level'),
|
||||
pattern: z.enum(['direct', 'smooth', 'bezier']).optional().default('direct').describe('Drag movement pattern'),
|
||||
steps: z.number().min(1).max(50).optional().default(10).describe('Number of intermediate steps for smooth/bezier patterns'),
|
||||
duration: z.number().min(100).max(10000).optional().describe('Total drag duration in milliseconds'),
|
||||
delay: z.number().min(0).max(5000).optional().describe('Delay before starting drag'),
|
||||
steps: z.coerce.number().min(1).max(50).optional().default(10).describe('Number of intermediate steps for smooth/bezier patterns'),
|
||||
duration: z.coerce.number().min(100).max(10000).optional().describe('Total drag duration in milliseconds'),
|
||||
delay: z.coerce.number().min(0).max(5000).optional().describe('Delay before starting drag'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
@ -209,8 +209,8 @@ const mouseScroll = defineTabTool({
|
||||
title: 'Scroll at coordinates',
|
||||
description: 'Perform scroll action at specific coordinates with precision control',
|
||||
inputSchema: elementSchema.extend(advancedCoordinateSchema.shape).extend({
|
||||
deltaX: z.number().optional().default(0).describe('Horizontal scroll amount (positive = right, negative = left)'),
|
||||
deltaY: z.number().describe('Vertical scroll amount (positive = down, negative = up)'),
|
||||
deltaX: z.coerce.number().optional().default(0).describe('Horizontal scroll amount (positive = right, negative = left)'),
|
||||
deltaY: z.coerce.number().describe('Vertical scroll amount (positive = down, negative = up)'),
|
||||
smooth: z.boolean().optional().default(false).describe('Use smooth scrolling animation'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
@ -256,9 +256,9 @@ const mouseGesture = defineTabTool({
|
||||
description: 'Perform complex mouse gestures with multiple waypoints',
|
||||
inputSchema: elementSchema.extend({
|
||||
points: z.array(z.object({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
delay: z.number().min(0).max(5000).optional().describe('Delay at this point in milliseconds'),
|
||||
x: z.coerce.number().describe('X coordinate'),
|
||||
y: z.coerce.number().describe('Y coordinate'),
|
||||
delay: z.coerce.number().min(0).max(5000).optional().describe('Delay at this point in milliseconds'),
|
||||
action: z.enum(['move', 'click', 'down', 'up']).optional().default('move').describe('Action at this point'),
|
||||
})).min(2).describe('Array of points defining the gesture path'),
|
||||
button: z.enum(['left', 'right', 'middle']).optional().default('left').describe('Mouse button for click actions'),
|
||||
|
||||
375
src/tools/network-throttle.ts
Normal file
375
src/tools/network-throttle.ts
Normal file
@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 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 } from './tool.js';
|
||||
|
||||
import type { Context } from '../context.js';
|
||||
import type { Response } from '../response.js';
|
||||
|
||||
// Network condition type for mutable use
|
||||
interface NetworkConditions {
|
||||
offline: boolean;
|
||||
latency: number;
|
||||
downloadThroughput: number;
|
||||
uploadThroughput: number;
|
||||
connectionType: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Network condition presets based on Chrome DevTools throttling profiles
|
||||
const networkPresets: Record<string, NetworkConditions> = {
|
||||
'offline': {
|
||||
offline: true,
|
||||
latency: 0,
|
||||
downloadThroughput: 0,
|
||||
uploadThroughput: 0,
|
||||
connectionType: 'none',
|
||||
description: 'No network connectivity - all requests will fail'
|
||||
},
|
||||
'slow-3g': {
|
||||
offline: false,
|
||||
latency: 2000,
|
||||
downloadThroughput: 50000, // 50 KB/s (400 kbps)
|
||||
uploadThroughput: 50000, // 50 KB/s (400 kbps)
|
||||
connectionType: 'cellular3g',
|
||||
description: 'Slow 3G: ~400 kbps, 2000ms latency - typical for poor mobile coverage'
|
||||
},
|
||||
'fast-3g': {
|
||||
offline: false,
|
||||
latency: 563,
|
||||
downloadThroughput: 180000, // ~180 KB/s (1.44 Mbps)
|
||||
uploadThroughput: 84375, // ~84 KB/s (675 kbps)
|
||||
connectionType: 'cellular3g',
|
||||
description: 'Fast 3G: ~1.5 Mbps down, 563ms latency - typical 3G connection'
|
||||
},
|
||||
'regular-4g': {
|
||||
offline: false,
|
||||
latency: 170,
|
||||
downloadThroughput: 1500000, // ~1.5 MB/s (12 Mbps)
|
||||
uploadThroughput: 750000, // ~750 KB/s (6 Mbps)
|
||||
connectionType: 'cellular4g',
|
||||
description: 'Regular 4G/LTE: ~12 Mbps down, 170ms latency - typical 4G connection'
|
||||
},
|
||||
'wifi': {
|
||||
offline: false,
|
||||
latency: 28,
|
||||
downloadThroughput: 3000000, // ~3 MB/s (24 Mbps)
|
||||
uploadThroughput: 1500000, // ~1.5 MB/s (12 Mbps)
|
||||
connectionType: 'wifi',
|
||||
description: 'WiFi: ~24 Mbps down, 28ms latency - typical home WiFi'
|
||||
},
|
||||
'no-throttle': {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1, // -1 means no throttling
|
||||
uploadThroughput: -1,
|
||||
connectionType: 'none',
|
||||
description: 'No throttling - use full network speed'
|
||||
}
|
||||
};
|
||||
|
||||
type NetworkPreset = 'offline' | 'slow-3g' | 'fast-3g' | 'regular-4g' | 'wifi' | 'no-throttle';
|
||||
|
||||
// Store current network conditions per context
|
||||
const currentNetworkConditions = new WeakMap<Context, {
|
||||
preset?: NetworkPreset;
|
||||
custom?: {
|
||||
offline: boolean;
|
||||
latency: number;
|
||||
downloadThroughput: number;
|
||||
uploadThroughput: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
const setNetworkConditionsSchema = z.object({
|
||||
preset: z.enum(['offline', 'slow-3g', 'fast-3g', 'regular-4g', 'wifi', 'no-throttle']).optional()
|
||||
.describe('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: z.coerce.number().min(-1).optional()
|
||||
.describe('Custom download speed in bytes/second. Use -1 for no throttling. Overrides preset if specified.'),
|
||||
uploadThroughput: z.coerce.number().min(-1).optional()
|
||||
.describe('Custom upload speed in bytes/second. Use -1 for no throttling. Overrides preset if specified.'),
|
||||
latency: z.coerce.number().min(0).optional()
|
||||
.describe('Custom latency in milliseconds to add to each request. Overrides preset if specified.'),
|
||||
offline: z.boolean().optional()
|
||||
.describe('Set to true to simulate offline mode. Overrides preset if specified.')
|
||||
});
|
||||
|
||||
const getNetworkConditionsSchema = z.object({});
|
||||
|
||||
const clearNetworkConditionsSchema = z.object({});
|
||||
|
||||
const setNetworkConditions = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: '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.`,
|
||||
inputSchema: setNetworkConditionsSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
handle: async (context: Context, params: z.output<typeof setNetworkConditionsSchema>, response: Response) => {
|
||||
const tab = context.currentTab();
|
||||
if (!tab)
|
||||
throw new Error('No active browser tab. Navigate to a page first.');
|
||||
|
||||
// Check if we're using Chromium
|
||||
if (context.config.browser.browserName !== 'chromium')
|
||||
throw new Error('Network throttling requires a Chromium-based browser (Chrome, Edge). Firefox and WebKit do not support CDP network emulation.');
|
||||
|
||||
// Validate that at least one parameter is provided
|
||||
if (!params.preset && params.downloadThroughput === undefined && params.uploadThroughput === undefined && params.latency === undefined && params.offline === undefined)
|
||||
throw new Error('Please specify a preset or at least one custom network condition (downloadThroughput, uploadThroughput, latency, or offline).');
|
||||
|
||||
try {
|
||||
// Create CDP session
|
||||
const cdpSession = await tab.page.context().newCDPSession(tab.page);
|
||||
|
||||
// Start with preset values if specified, otherwise use no-throttle as base
|
||||
let conditions = params.preset
|
||||
? { ...networkPresets[params.preset] }
|
||||
: { ...networkPresets['no-throttle'] };
|
||||
|
||||
// Override with any custom values
|
||||
if (params.offline !== undefined)
|
||||
conditions.offline = params.offline;
|
||||
if (params.latency !== undefined)
|
||||
conditions.latency = params.latency;
|
||||
if (params.downloadThroughput !== undefined)
|
||||
conditions.downloadThroughput = params.downloadThroughput;
|
||||
if (params.uploadThroughput !== undefined)
|
||||
conditions.uploadThroughput = params.uploadThroughput;
|
||||
|
||||
// Apply network conditions via CDP
|
||||
// Cast connectionType to the expected CDP Protocol enum type
|
||||
await cdpSession.send('Network.emulateNetworkConditions', {
|
||||
offline: conditions.offline,
|
||||
latency: conditions.latency,
|
||||
downloadThroughput: conditions.downloadThroughput,
|
||||
uploadThroughput: conditions.uploadThroughput,
|
||||
connectionType: (conditions.connectionType || 'none') as 'none' | 'cellular2g' | 'cellular3g' | 'cellular4g' | 'bluetooth' | 'ethernet' | 'wifi' | 'wimax' | 'other'
|
||||
});
|
||||
|
||||
// Store current conditions for later retrieval
|
||||
const conditionsToStore: {
|
||||
preset?: NetworkPreset;
|
||||
custom?: {
|
||||
offline: boolean;
|
||||
latency: number;
|
||||
downloadThroughput: number;
|
||||
uploadThroughput: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
if (params.preset && !params.downloadThroughput && !params.uploadThroughput && !params.latency && params.offline === undefined) {
|
||||
conditionsToStore.preset = params.preset;
|
||||
} else {
|
||||
conditionsToStore.custom = {
|
||||
offline: conditions.offline,
|
||||
latency: conditions.latency,
|
||||
downloadThroughput: conditions.downloadThroughput,
|
||||
uploadThroughput: conditions.uploadThroughput
|
||||
};
|
||||
if (params.preset)
|
||||
conditionsToStore.preset = params.preset;
|
||||
}
|
||||
|
||||
currentNetworkConditions.set(context, conditionsToStore);
|
||||
|
||||
// Build response message
|
||||
const lines: string[] = [];
|
||||
|
||||
if (conditions.offline) {
|
||||
lines.push('**Network Status:** OFFLINE');
|
||||
lines.push('All network requests will be blocked.');
|
||||
} else if (conditions.downloadThroughput === -1 && conditions.uploadThroughput === -1 && conditions.latency === 0) {
|
||||
lines.push('**Network Status:** No throttling');
|
||||
lines.push('Network requests will use full available bandwidth.');
|
||||
} else {
|
||||
lines.push('**Network Throttling Active**');
|
||||
lines.push('');
|
||||
|
||||
if (params.preset) {
|
||||
const presetInfo = networkPresets[params.preset];
|
||||
lines.push(`Preset: **${params.preset}**`);
|
||||
lines.push(`${presetInfo.description}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('**Current Settings:**');
|
||||
lines.push(`- Download: ${formatThroughput(conditions.downloadThroughput)}`);
|
||||
lines.push(`- Upload: ${formatThroughput(conditions.uploadThroughput)}`);
|
||||
lines.push(`- Latency: ${conditions.latency}ms added to each request`);
|
||||
}
|
||||
|
||||
response.addResult(lines.join('\n'));
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Target closed'))
|
||||
throw new Error('Browser connection lost. Please navigate to a page first.');
|
||||
throw new Error(`Failed to set network conditions: ${error}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getNetworkConditions = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: '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.',
|
||||
inputSchema: getNetworkConditionsSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async (context: Context, _params: z.output<typeof getNetworkConditionsSchema>, response: Response) => {
|
||||
const tab = context.currentTab();
|
||||
if (!tab)
|
||||
throw new Error('No active browser tab. Navigate to a page first.');
|
||||
|
||||
// Check if we're using Chromium
|
||||
if (context.config.browser.browserName !== 'chromium') {
|
||||
response.addResult('Network throttling is only available for Chromium-based browsers (Chrome, Edge).\n\nCurrent browser: ' + context.config.browser.browserName);
|
||||
return;
|
||||
}
|
||||
|
||||
const conditions = currentNetworkConditions.get(context);
|
||||
|
||||
if (!conditions) {
|
||||
response.addResult(
|
||||
'**Network Throttling:** Not configured\n\n' +
|
||||
'Network requests are using full available bandwidth.\n\n' +
|
||||
'Use `browser_set_network_conditions` to simulate slow network conditions.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = ['**Current Network Conditions:**', ''];
|
||||
|
||||
if (conditions.preset) {
|
||||
const presetInfo = networkPresets[conditions.preset];
|
||||
lines.push(`Preset: **${conditions.preset}**`);
|
||||
lines.push(`${presetInfo.description}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (conditions.custom) {
|
||||
if (conditions.custom.offline) {
|
||||
lines.push('**Status:** OFFLINE');
|
||||
lines.push('All network requests are being blocked.');
|
||||
} else {
|
||||
lines.push('**Settings:**');
|
||||
lines.push(`- Download: ${formatThroughput(conditions.custom.downloadThroughput)}`);
|
||||
lines.push(`- Upload: ${formatThroughput(conditions.custom.uploadThroughput)}`);
|
||||
lines.push(`- Latency: ${conditions.custom.latency}ms`);
|
||||
}
|
||||
} else if (conditions.preset) {
|
||||
const preset = networkPresets[conditions.preset];
|
||||
if (preset.offline) {
|
||||
lines.push('**Status:** OFFLINE');
|
||||
} else if (preset.downloadThroughput === -1) {
|
||||
lines.push('**Status:** No throttling');
|
||||
} else {
|
||||
lines.push('**Settings:**');
|
||||
lines.push(`- Download: ${formatThroughput(preset.downloadThroughput)}`);
|
||||
lines.push(`- Upload: ${formatThroughput(preset.uploadThroughput)}`);
|
||||
lines.push(`- Latency: ${preset.latency}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
response.addResult(lines.join('\n'));
|
||||
},
|
||||
});
|
||||
|
||||
const clearNetworkConditions = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_clear_network_conditions',
|
||||
title: 'Remove network throttling',
|
||||
description: 'Remove all network throttling and restore full network speed. Equivalent to setting preset "no-throttle".',
|
||||
inputSchema: clearNetworkConditionsSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
handle: async (context: Context, _params: z.output<typeof clearNetworkConditionsSchema>, response: Response) => {
|
||||
const tab = context.currentTab();
|
||||
if (!tab)
|
||||
throw new Error('No active browser tab. Navigate to a page first.');
|
||||
|
||||
// Check if we're using Chromium
|
||||
if (context.config.browser.browserName !== 'chromium')
|
||||
throw new Error('Network throttling requires a Chromium-based browser (Chrome, Edge).');
|
||||
|
||||
try {
|
||||
// Create CDP session and disable throttling
|
||||
const cdpSession = await tab.page.context().newCDPSession(tab.page);
|
||||
|
||||
await cdpSession.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: -1,
|
||||
connectionType: 'none'
|
||||
});
|
||||
|
||||
// Clear stored conditions
|
||||
currentNetworkConditions.delete(context);
|
||||
|
||||
response.addResult(
|
||||
'**Network Throttling Removed**\n\n' +
|
||||
'Network requests will now use full available bandwidth.\n\n' +
|
||||
'Use `browser_set_network_conditions` to re-enable throttling.'
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Target closed'))
|
||||
throw new Error('Browser connection lost. Please navigate to a page first.');
|
||||
throw new Error(`Failed to clear network conditions: ${error}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Format throughput value for display
|
||||
*/
|
||||
function formatThroughput(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond === -1)
|
||||
return 'No limit';
|
||||
if (bytesPerSecond === 0)
|
||||
return 'Blocked';
|
||||
|
||||
if (bytesPerSecond >= 1000000)
|
||||
return `${(bytesPerSecond / 1000000).toFixed(1)} MB/s (${(bytesPerSecond * 8 / 1000000).toFixed(1)} Mbps)`;
|
||||
|
||||
if (bytesPerSecond >= 1000)
|
||||
return `${(bytesPerSecond / 1000).toFixed(1)} KB/s (${(bytesPerSecond * 8 / 1000).toFixed(1)} kbps)`;
|
||||
|
||||
return `${bytesPerSecond} B/s`;
|
||||
}
|
||||
|
||||
export default [
|
||||
setNetworkConditions,
|
||||
getNetworkConditions,
|
||||
clearNetworkConditions,
|
||||
];
|
||||
@ -166,7 +166,7 @@ const waitForNotification = defineTabTool({
|
||||
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)'),
|
||||
timeout: z.coerce.number().optional().describe('Maximum time to wait in milliseconds (default: 30000)'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
@ -354,9 +354,9 @@ const setGeolocation = defineTool({
|
||||
title: 'Set geolocation at runtime',
|
||||
description: 'Set the browser\'s geolocation at runtime without restarting. Automatically grants geolocation permission.',
|
||||
inputSchema: z.object({
|
||||
latitude: z.number().min(-90).max(90).describe('Latitude coordinate (-90 to 90)'),
|
||||
longitude: z.number().min(-180).max(180).describe('Longitude coordinate (-180 to 180)'),
|
||||
accuracy: z.number().optional().describe('Accuracy in meters (default: 100)'),
|
||||
latitude: z.coerce.number().min(-90).max(90).describe('Latitude coordinate (-90 to 90)'),
|
||||
longitude: z.coerce.number().min(-180).max(180).describe('Longitude coordinate (-180 to 180)'),
|
||||
accuracy: z.coerce.number().optional().describe('Accuracy in meters (default: 100)'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
753
src/tools/pwa.ts
Normal file
753
src/tools/pwa.ts
Normal 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.coerce.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,
|
||||
];
|
||||
@ -31,7 +31,7 @@ const startMonitoringSchema = z.object({
|
||||
|
||||
captureBody: z.boolean().optional().default(true).describe('Whether to capture request and response bodies (default: true)'),
|
||||
|
||||
maxBodySize: z.number().optional().default(10485760).describe('Maximum body size to capture in bytes (default: 10MB). Larger bodies will be truncated'),
|
||||
maxBodySize: z.coerce.number().optional().default(10485760).describe('Maximum body size to capture in bytes (default: 10MB). Larger bodies will be truncated'),
|
||||
|
||||
autoSave: z.boolean().optional().default(false).describe('Automatically save captured requests after each response (default: false for performance)'),
|
||||
|
||||
@ -45,11 +45,11 @@ const getRequestsSchema = paginationParamsSchema.extend({
|
||||
|
||||
method: z.string().optional().describe('Filter requests by HTTP method (GET, POST, etc.)'),
|
||||
|
||||
status: z.number().optional().describe('Filter requests by HTTP status code'),
|
||||
status: z.coerce.number().optional().describe('Filter requests by HTTP status code'),
|
||||
|
||||
format: z.enum(['summary', 'detailed', 'stats']).optional().default('summary').describe('Response format: summary (basic info), detailed (full data), stats (statistics only)'),
|
||||
|
||||
slowThreshold: z.number().optional().default(1000).describe('Threshold in milliseconds for considering requests "slow" (default: 1000ms)')
|
||||
slowThreshold: z.coerce.number().optional().default(1000).describe('Threshold in milliseconds for considering requests "slow" (default: 1000ms)')
|
||||
});
|
||||
|
||||
const exportRequestsSchema = z.object({
|
||||
|
||||
@ -42,9 +42,9 @@ const setDeviceOrientation = defineTabTool({
|
||||
|
||||
**Note:** Requires Chromium-based browser. This overrides the DeviceOrientationEvent.`,
|
||||
inputSchema: z.object({
|
||||
alpha: z.number().min(0).max(360).describe('Compass heading (0-360 degrees). 0=North, 90=East, 180=South, 270=West'),
|
||||
beta: z.number().min(-180).max(180).describe('Front-to-back tilt (-180 to 180 degrees). Positive=tilted backward'),
|
||||
gamma: z.number().min(-90).max(90).describe('Left-to-right tilt (-90 to 90 degrees). Positive=tilted right'),
|
||||
alpha: z.coerce.number().min(0).max(360).describe('Compass heading (0-360 degrees). 0=North, 90=East, 180=South, 270=West'),
|
||||
beta: z.coerce.number().min(-180).max(180).describe('Front-to-back tilt (-180 to 180 degrees). Positive=tilted backward'),
|
||||
gamma: z.coerce.number().min(-90).max(90).describe('Left-to-right tilt (-90 to 90 degrees). Positive=tilted right'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
@ -144,21 +144,21 @@ const setDeviceMotion = defineTabTool({
|
||||
**Note:** Requires Chromium-based browser.`,
|
||||
inputSchema: z.object({
|
||||
acceleration: z.object({
|
||||
x: z.number().describe('Acceleration on x-axis (m/s²)'),
|
||||
y: z.number().describe('Acceleration on y-axis (m/s²)'),
|
||||
z: z.number().describe('Acceleration on z-axis (m/s²)'),
|
||||
x: z.coerce.number().describe('Acceleration on x-axis (m/s²)'),
|
||||
y: z.coerce.number().describe('Acceleration on y-axis (m/s²)'),
|
||||
z: z.coerce.number().describe('Acceleration on z-axis (m/s²)'),
|
||||
}).optional().describe('Linear acceleration excluding gravity'),
|
||||
accelerationIncludingGravity: z.object({
|
||||
x: z.number().describe('Acceleration on x-axis including gravity (m/s²)'),
|
||||
y: z.number().describe('Acceleration on y-axis including gravity (m/s²)'),
|
||||
z: z.number().describe('Acceleration on z-axis including gravity (m/s²)'),
|
||||
x: z.coerce.number().describe('Acceleration on x-axis including gravity (m/s²)'),
|
||||
y: z.coerce.number().describe('Acceleration on y-axis including gravity (m/s²)'),
|
||||
z: z.coerce.number().describe('Acceleration on z-axis including gravity (m/s²)'),
|
||||
}).optional().describe('Total acceleration including gravity'),
|
||||
rotationRate: z.object({
|
||||
alpha: z.number().describe('Rotation rate around z-axis (deg/s)'),
|
||||
beta: z.number().describe('Rotation rate around x-axis (deg/s)'),
|
||||
gamma: z.number().describe('Rotation rate around y-axis (deg/s)'),
|
||||
alpha: z.coerce.number().describe('Rotation rate around z-axis (deg/s)'),
|
||||
beta: z.coerce.number().describe('Rotation rate around x-axis (deg/s)'),
|
||||
gamma: z.coerce.number().describe('Rotation rate around y-axis (deg/s)'),
|
||||
}).optional().describe('Angular velocity around each axis'),
|
||||
interval: z.number().optional().describe('Interval between samples in milliseconds (default: 16)'),
|
||||
interval: z.coerce.number().optional().describe('Interval between samples in milliseconds (default: 16)'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
348
src/tools/storage.ts
Normal file
348
src/tools/storage.ts
Normal file
@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Get all cookies for the current browser context.
|
||||
* Uses browserContext.cookies() to retrieve cookie data.
|
||||
*/
|
||||
const getCookies = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_get_cookies',
|
||||
title: 'Get browser cookies',
|
||||
description: 'List all cookies for the current browser context. Optionally filter by domain or URL.',
|
||||
inputSchema: z.object({
|
||||
urls: z.array(z.string()).optional().describe('Filter cookies by specific URLs. If not provided, returns all cookies.'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
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 cookies = params.urls && params.urls.length > 0
|
||||
? await browserContext.cookies(params.urls)
|
||||
: await browserContext.cookies();
|
||||
|
||||
if (cookies.length === 0) {
|
||||
response.addResult('No cookies found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = ['### Browser Cookies', ''];
|
||||
for (const cookie of cookies) {
|
||||
result.push(`- **${cookie.name}**`);
|
||||
result.push(` - Value: \`${cookie.value.substring(0, 100)}${cookie.value.length > 100 ? '...' : ''}\``);
|
||||
result.push(` - Domain: ${cookie.domain}`);
|
||||
result.push(` - Path: ${cookie.path}`);
|
||||
result.push(` - Expires: ${cookie.expires === -1 ? 'Session' : new Date(cookie.expires * 1000).toISOString()}`);
|
||||
result.push(` - HttpOnly: ${cookie.httpOnly}`);
|
||||
result.push(` - Secure: ${cookie.secure}`);
|
||||
result.push(` - SameSite: ${cookie.sameSite}`);
|
||||
result.push('');
|
||||
}
|
||||
|
||||
result.push(`**Total:** ${cookies.length} cookie(s)`);
|
||||
response.addResult(result.join('\n'));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Set a cookie in the browser context.
|
||||
* Uses browserContext.addCookies() to add cookie data.
|
||||
*/
|
||||
const setCookie = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: '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.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Cookie name'),
|
||||
value: z.string().describe('Cookie value'),
|
||||
url: z.string().optional().describe('URL to associate with the cookie. Either url or domain must be specified.'),
|
||||
domain: z.string().optional().describe('Cookie domain. Either url or domain must be specified.'),
|
||||
path: z.string().optional().describe('Cookie path (default: "/")'),
|
||||
expires: z.coerce.number().optional().describe('Unix timestamp in seconds for cookie expiration. -1 for session cookie.'),
|
||||
httpOnly: z.boolean().optional().describe('Whether the cookie is HTTP only (default: false)'),
|
||||
secure: z.boolean().optional().describe('Whether the cookie is secure (default: false)'),
|
||||
sameSite: z.enum(['Strict', 'Lax', 'None']).optional().describe('SameSite attribute (default: "Lax")'),
|
||||
}),
|
||||
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.');
|
||||
|
||||
if (!params.url && !params.domain)
|
||||
throw new Error('Either "url" or "domain" must be specified.');
|
||||
|
||||
const cookie: {
|
||||
name: string;
|
||||
value: string;
|
||||
url?: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
expires?: number;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
sameSite?: 'Strict' | 'Lax' | 'None';
|
||||
} = {
|
||||
name: params.name,
|
||||
value: params.value,
|
||||
};
|
||||
|
||||
if (params.url)
|
||||
cookie.url = params.url;
|
||||
if (params.domain)
|
||||
cookie.domain = params.domain;
|
||||
if (params.path)
|
||||
cookie.path = params.path;
|
||||
if (params.expires !== undefined)
|
||||
cookie.expires = params.expires;
|
||||
if (params.httpOnly !== undefined)
|
||||
cookie.httpOnly = params.httpOnly;
|
||||
if (params.secure !== undefined)
|
||||
cookie.secure = params.secure;
|
||||
if (params.sameSite)
|
||||
cookie.sameSite = params.sameSite;
|
||||
|
||||
await browserContext.addCookies([cookie]);
|
||||
|
||||
const location = params.url || `${params.domain}${params.path || '/'}`;
|
||||
response.addResult(`Cookie "${params.name}" set successfully for ${location}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete cookies from the browser context.
|
||||
* Uses browserContext.clearCookies() with optional filters.
|
||||
*/
|
||||
const deleteCookies = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_delete_cookies',
|
||||
title: 'Delete browser cookies',
|
||||
description: 'Delete cookies by name, domain, or path. If no filters provided, clears all cookies.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().optional().describe('Delete cookies with this name'),
|
||||
domain: z.string().optional().describe('Delete cookies for this domain'),
|
||||
path: z.string().optional().describe('Delete cookies with this path'),
|
||||
}),
|
||||
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 hasFilters = params.name || params.domain || params.path;
|
||||
|
||||
if (hasFilters) {
|
||||
// Build filter object for clearCookies
|
||||
const filter: { name?: string; domain?: string; path?: string } = {};
|
||||
if (params.name)
|
||||
filter.name = params.name;
|
||||
if (params.domain)
|
||||
filter.domain = params.domain;
|
||||
if (params.path)
|
||||
filter.path = params.path;
|
||||
|
||||
await browserContext.clearCookies(filter);
|
||||
|
||||
const filterParts: string[] = [];
|
||||
if (params.name)
|
||||
filterParts.push(`name="${params.name}"`);
|
||||
if (params.domain)
|
||||
filterParts.push(`domain="${params.domain}"`);
|
||||
if (params.path)
|
||||
filterParts.push(`path="${params.path}"`);
|
||||
|
||||
response.addResult(`Cookies deleted matching: ${filterParts.join(', ')}`);
|
||||
} else {
|
||||
await browserContext.clearCookies();
|
||||
response.addResult('All cookies have been cleared.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get localStorage or sessionStorage contents.
|
||||
* Uses page.evaluate() to access web storage APIs.
|
||||
*/
|
||||
const getStorage = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_get_storage',
|
||||
title: 'Get web storage contents',
|
||||
description: 'Get all key-value pairs from localStorage or sessionStorage for the current page.',
|
||||
inputSchema: z.object({
|
||||
type: z.enum(['local', 'session']).describe('Storage type: "local" for localStorage, "session" for sessionStorage'),
|
||||
key: z.string().optional().describe('Get a specific key value instead of all items'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const storageType = params.type === 'local' ? 'localStorage' : 'sessionStorage';
|
||||
|
||||
if (params.key) {
|
||||
// Get a specific key
|
||||
const value = await tab.page.evaluate(
|
||||
([type, key]) => {
|
||||
const storage = type === 'local' ? localStorage : sessionStorage;
|
||||
return storage.getItem(key);
|
||||
},
|
||||
[params.type, params.key] as const
|
||||
);
|
||||
|
||||
if (value === null) {
|
||||
response.addResult(`Key "${params.key}" not found in ${storageType}.`);
|
||||
} else {
|
||||
response.addResult(`**${storageType}["${params.key}"]:**\n\`\`\`\n${value}\n\`\`\``);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all items
|
||||
const items = await tab.page.evaluate(
|
||||
(type) => {
|
||||
const storage = type === 'local' ? localStorage : sessionStorage;
|
||||
const result: Record<string, string> = {};
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key !== null)
|
||||
result[key] = storage.getItem(key) || '';
|
||||
}
|
||||
return result;
|
||||
},
|
||||
params.type
|
||||
);
|
||||
|
||||
const keys = Object.keys(items);
|
||||
if (keys.length === 0) {
|
||||
response.addResult(`${storageType} is empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = [`### ${storageType} Contents`, ''];
|
||||
for (const key of keys) {
|
||||
const value = items[key];
|
||||
const truncatedValue = value.length > 200 ? value.substring(0, 200) + '...' : value;
|
||||
result.push(`- **${key}:**`);
|
||||
result.push(` \`\`\`${truncatedValue}\`\`\``);
|
||||
result.push('');
|
||||
}
|
||||
|
||||
result.push(`**Total:** ${keys.length} item(s)`);
|
||||
response.addResult(result.join('\n'));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Set a key-value pair in localStorage or sessionStorage.
|
||||
* Uses page.evaluate() to access web storage APIs.
|
||||
*/
|
||||
const setStorage = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_set_storage',
|
||||
title: 'Set web storage item',
|
||||
description: 'Set a key-value pair in localStorage or sessionStorage for the current page.',
|
||||
inputSchema: z.object({
|
||||
type: z.enum(['local', 'session']).describe('Storage type: "local" for localStorage, "session" for sessionStorage'),
|
||||
key: z.string().describe('Storage key'),
|
||||
value: z.string().describe('Storage value (will be stored as string)'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const storageType = params.type === 'local' ? 'localStorage' : 'sessionStorage';
|
||||
|
||||
await tab.page.evaluate(
|
||||
([type, key, value]) => {
|
||||
const storage = type === 'local' ? localStorage : sessionStorage;
|
||||
storage.setItem(key, value);
|
||||
},
|
||||
[params.type, params.key, params.value] as const
|
||||
);
|
||||
|
||||
response.addResult(`Set ${storageType}["${params.key}"] successfully.`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear localStorage, sessionStorage, or both.
|
||||
* Uses page.evaluate() to access web storage APIs.
|
||||
*/
|
||||
const clearStorage = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_clear_storage',
|
||||
title: 'Clear web storage',
|
||||
description: 'Clear localStorage, sessionStorage, or both for the current page.',
|
||||
inputSchema: z.object({
|
||||
type: z.enum(['local', 'session', 'both']).describe('Storage type to clear: "local", "session", or "both"'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const clearLocal = params.type === 'local' || params.type === 'both';
|
||||
const clearSession = params.type === 'session' || params.type === 'both';
|
||||
|
||||
await tab.page.evaluate(
|
||||
([doLocal, doSession]) => {
|
||||
if (doLocal)
|
||||
localStorage.clear();
|
||||
if (doSession)
|
||||
sessionStorage.clear();
|
||||
},
|
||||
[clearLocal, clearSession] as const
|
||||
);
|
||||
|
||||
if (params.type === 'both') {
|
||||
response.addResult('Both localStorage and sessionStorage have been cleared.');
|
||||
} else {
|
||||
const storageType = params.type === 'local' ? 'localStorage' : 'sessionStorage';
|
||||
response.addResult(`${storageType} has been cleared.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
getCookies,
|
||||
setCookie,
|
||||
deleteCookies,
|
||||
getStorage,
|
||||
setStorage,
|
||||
clearStorage,
|
||||
];
|
||||
@ -42,7 +42,7 @@ const selectTab = defineTool({
|
||||
title: 'Select a tab',
|
||||
description: 'Select a tab by index. Returns page snapshot after selecting tab (configurable via browser_configure_snapshots).',
|
||||
inputSchema: z.object({
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
index: z.coerce.number().describe('The index of the tab to select'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
@ -82,7 +82,7 @@ const closeTab = defineTool({
|
||||
title: 'Close a tab',
|
||||
description: 'Close a tab. Returns page snapshot after closing tab (configurable via browser_configure_snapshots).',
|
||||
inputSchema: z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
index: z.coerce.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
@ -28,8 +28,8 @@ const startRecording = defineTool({
|
||||
description: 'Start recording browser session video with intelligent viewport matching. For best results, the browser viewport size should match the video recording size to avoid gray space around content. Use browser_configure to set viewport size before recording.',
|
||||
inputSchema: z.object({
|
||||
size: z.object({
|
||||
width: z.number().optional().describe('Video width in pixels (default: 1280). For full-frame content, set browser viewport to match this width.'),
|
||||
height: z.number().optional().describe('Video height in pixels (default: 720). For full-frame content, set browser viewport to match this height.'),
|
||||
width: z.coerce.number().optional().describe('Video width in pixels (default: 1280). For full-frame content, set browser viewport to match this width.'),
|
||||
height: z.coerce.number().optional().describe('Video height in pixels (default: 720). For full-frame content, set browser viewport to match this height.'),
|
||||
}).optional().describe('Video recording dimensions. IMPORTANT: Browser viewport should match these dimensions to avoid gray borders around content.'),
|
||||
filename: z.string().optional().describe('Base filename for video files (default: session-{timestamp}.webm)'),
|
||||
autoSetViewport: z.boolean().optional().default(true).describe('Automatically set browser viewport to match video recording size (recommended for full-frame content)'),
|
||||
|
||||
@ -25,7 +25,7 @@ const wait = defineTool({
|
||||
title: 'Wait for',
|
||||
description: 'Wait for text to appear or disappear or a specified time to pass. In smart recording mode, video recording is automatically paused during waits unless recordDuringWait is true.',
|
||||
inputSchema: z.object({
|
||||
time: z.number().optional().describe('The time to wait in seconds'),
|
||||
time: z.coerce.number().optional().describe('The time to wait in seconds'),
|
||||
text: z.string().optional().describe('The text to wait for'),
|
||||
textGone: z.string().optional().describe('The text to wait for to disappear'),
|
||||
recordDuringWait: z.boolean().optional().default(false).describe('Whether to keep video recording active during the wait (default: false in smart mode, true in continuous mode)'),
|
||||
|
||||
@ -28,7 +28,7 @@ const startWebRTCMonitoring = defineTabTool({
|
||||
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.',
|
||||
inputSchema: z.object({
|
||||
statsPollingInterval: z.number().optional().describe('Stats collection interval in milliseconds (default: 1000ms). Lower values give more frequent updates but use more CPU.'),
|
||||
statsPollingInterval: z.coerce.number().optional().describe('Stats collection interval in milliseconds (default: 1000ms). Lower values give more frequent updates but use more CPU.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
@ -22,10 +22,12 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_clear_device_motion',
|
||||
'browser_clear_device_orientation',
|
||||
'browser_clear_injections',
|
||||
'browser_clear_webrtc_data',
|
||||
'browser_clear_network_conditions',
|
||||
'browser_clear_notifications',
|
||||
'browser_clear_permissions',
|
||||
'browser_clear_requests',
|
||||
'browser_clear_storage',
|
||||
'browser_clear_webrtc_data',
|
||||
'browser_click',
|
||||
'browser_close',
|
||||
'browser_configure',
|
||||
@ -33,6 +35,7 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_configure_notifications',
|
||||
'browser_configure_snapshots',
|
||||
'browser_console_messages',
|
||||
'browser_delete_cookies',
|
||||
'browser_disable_debug_toolbar',
|
||||
'browser_dismiss_all_file_choosers',
|
||||
'browser_dismiss_file_chooser',
|
||||
@ -43,7 +46,10 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_export_requests',
|
||||
'browser_file_upload',
|
||||
'browser_get_artifact_paths',
|
||||
'browser_get_cookies',
|
||||
'browser_get_network_conditions',
|
||||
'browser_get_requests',
|
||||
'browser_get_storage',
|
||||
'browser_get_webrtc_connections',
|
||||
'browser_get_webrtc_stats',
|
||||
'browser_grant_permissions',
|
||||
@ -69,17 +75,22 @@ 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',
|
||||
'browser_resume_recording',
|
||||
'browser_reveal_artifact_paths',
|
||||
'browser_select_option',
|
||||
'browser_set_cookie',
|
||||
'browser_set_device_motion',
|
||||
'browser_set_device_orientation',
|
||||
'browser_set_geolocation',
|
||||
'browser_set_network_conditions',
|
||||
'browser_set_offline',
|
||||
'browser_set_recording_mode',
|
||||
'browser_set_storage',
|
||||
'browser_snapshot',
|
||||
'browser_start_recording',
|
||||
'browser_start_request_monitoring',
|
||||
|
||||
364
tests/pwa.spec.ts
Normal file
364
tests/pwa.spec.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user