mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
feat: add PWA automation tools (#2430)
## What / Why Adds an opt-in Progressive Web Apps tool category that exposes the installed web app lifecycle to MCP clients and agents. The browser-level PWA APIs were introduced in Puppeteer 25.4.0 (puppeteer/puppeteer#15235). The current base uses Puppeteer 25.5.0, which also enforces URL restrictions for browser-level PWA operations (puppeteer/puppeteer#15271). ## Tools - `install_pwa` - `launch_pwa` - `get_os_app_state` - `uninstall_pwa` The category is disabled by default and enabled with `--category-pwa`. These are browser-scoped operations and do not require a selected page. Callers provide the resolved manifest ID and, for installation, an explicit page or bundle URL. `open_current_page_in_app` and automatic current-page installation are intentionally deferred until Puppeteer exposes page-native APIs with reliable loaded-document and browser-context semantics. ## Compatibility and safety - Requires a pipe-launched browser. The CLI rejects `--category-pwa` when combined with `--auto-connect`, `--browser-url`, or `--ws-endpoint`. - Puppeteer rejects PWA operations when URL allow/block restrictions are configured, preventing browser-internal PWA fetch and redirect flows from bypassing the configured policy. - File-based install bundles are validated against the MCP filesystem roots. - DevTools installation defaults to browser display mode unless `displayMode: "standalone"` is supplied. Launch uses the app's saved display preference. ## Testing - PWA tests cover lifecycle/state, standalone launch, successful explicit-URL launch, use without a selected page, file-bundle path validation, OS-state output, and selected-page fallback after uninstall. - Existing off-by-default category tests cover PWA tool registration and opt-in exposure. - Generated README, tool reference, CLI metadata, and telemetry metrics are updated. - Type checking, formatting, targeted tests, broad supporting repository tests, third-party notice validation, and diff checks pass locally. - Validated against Chrome for Testing 150 and a local Chromium/Edge build. - A real stdio MCP lifecycle passed end to end with the final four-tool design: category gating, explicit install, OS state, standalone launch, selecting and scripting the returned page, uninstall, selection fallback, and post-uninstall state rejection. Refs #2270
This commit is contained in:
@@ -582,6 +582,11 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
|
||||
- **WebMCP** (2 tools)
|
||||
- [`execute_webmcp_tool`](docs/tool-reference.md#execute_webmcp_tool)
|
||||
- [`list_webmcp_tools`](docs/tool-reference.md#list_webmcp_tools)
|
||||
- **Progressive Web Apps** (4 tools)
|
||||
- [`get_os_app_state`](docs/tool-reference.md#get_os_app_state)
|
||||
- [`install_pwa`](docs/tool-reference.md#install_pwa)
|
||||
- [`launch_pwa`](docs/tool-reference.md#launch_pwa)
|
||||
- [`uninstall_pwa`](docs/tool-reference.md#uninstall_pwa)
|
||||
|
||||
<!-- END AUTO GENERATED TOOLS -->
|
||||
|
||||
@@ -747,6 +752,11 @@ The Chrome DevTools MCP server supports the following configuration option:
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--categoryPwa`/ `--category-pwa`**
|
||||
Set to true to include tools for automating Progressive Web Apps (install, launch, uninstall, and OS state). This feature is only supported with a pipe connection; autoConnect, browserUrl, and wsEndpoint are not supported.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
- **`--performanceCrux`/ `--performance-crux`**
|
||||
Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.
|
||||
- **Type:** boolean
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
- **[WebMCP](#webmcp)** (2 tools)
|
||||
- [`execute_webmcp_tool`](#execute_webmcp_tool)
|
||||
- [`list_webmcp_tools`](#list_webmcp_tools)
|
||||
- **[Progressive Web Apps](#progressive-web-apps)** (4 tools)
|
||||
- [`get_os_app_state`](#get_os_app_state)
|
||||
- [`install_pwa`](#install_pwa)
|
||||
- [`launch_pwa`](#launch_pwa)
|
||||
- [`uninstall_pwa`](#uninstall_pwa)
|
||||
|
||||
## Input automation
|
||||
|
||||
@@ -695,3 +700,50 @@ third-party developer tools with additional functionality. (requires flag: --cat
|
||||
**Parameters:** None
|
||||
|
||||
---
|
||||
|
||||
## Progressive Web Apps
|
||||
|
||||
> NOTE: The Progressive Web Apps category is not active by default. Use the '--categoryPwa' flag.
|
||||
|
||||
### `get_os_app_state`
|
||||
|
||||
**Description:** Returns the OS integration state (badge count and registered file handlers) for an installed web app, identified by its manifest ID. (requires flag: --categoryPwa=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **manifestId** (string) **(required)**: The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.
|
||||
|
||||
---
|
||||
|
||||
### `install_pwa`
|
||||
|
||||
**Description:** Installs a Progressive Web App (PWA) identified by its manifest ID. This installs through the PWA CDP domain without a user gesture or install dialog. DevTools installs default to browser display mode. (requires flag: --categoryPwa=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **installUrlOrBundleUrl** (string) **(required)**: The location of the app or bundle. For a normal site this is the page URL; for an Isolated Web App it can be a file:// or http(s):// signed web bundle.
|
||||
- **manifestId** (string) **(required)**: The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.
|
||||
- **displayMode** (enum: "standalone", "browser") _(optional)_: Optional user display mode preference applied after install. "standalone" opens the app in its own window; "browser" opens it as a tab. Installs via the PWA CDP domain default to "browser" because they do not simulate the install dialog, so pass "standalone" to get an app-window experience.
|
||||
|
||||
---
|
||||
|
||||
### `launch_pwa`
|
||||
|
||||
**Description:** Launches an installed Progressive Web App using its saved display mode. Optionally opens a specific URL within the same app instead of the default start URL. (requires flag: --categoryPwa=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **manifestId** (string) **(required)**: The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.
|
||||
- **url** (string) _(optional)_: Optional URL within the app to open instead of the default start URL.
|
||||
|
||||
---
|
||||
|
||||
### `uninstall_pwa`
|
||||
|
||||
**Description:** Uninstalls a Progressive Web App identified by its manifest ID and closes any open app windows. (requires flag: --categoryPwa=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **manifestId** (string) **(required)**: The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,9 +25,14 @@ import {
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type ConsoleMessage,
|
||||
type GetPWAStateOptions,
|
||||
type InstallPWAOptions,
|
||||
type LaunchPWAOptions,
|
||||
type Page,
|
||||
type PWAState,
|
||||
type ScreenRecorder,
|
||||
type Target,
|
||||
type UninstallPWAOptions,
|
||||
type Extension,
|
||||
type Root,
|
||||
type DevTools,
|
||||
@@ -339,6 +344,22 @@ export class McpContext implements Context {
|
||||
return !!(this.#options.allowList || this.#options.blocklist);
|
||||
}
|
||||
|
||||
installPWA(options: InstallPWAOptions): Promise<string> {
|
||||
return this.browser.installPWA(options);
|
||||
}
|
||||
|
||||
uninstallPWA(options: UninstallPWAOptions): Promise<void> {
|
||||
return this.browser.uninstallPWA(options);
|
||||
}
|
||||
|
||||
launchPWA(options: LaunchPWAOptions): Promise<Page> {
|
||||
return this.browser.launchPWA(options);
|
||||
}
|
||||
|
||||
getPWAState(options: GetPWAStateOptions): Promise<PWAState> {
|
||||
return this.browser.getPWAState(options);
|
||||
}
|
||||
|
||||
setIsRunningPerformanceTrace(x: boolean): void {
|
||||
this.#isRunningTrace = x;
|
||||
}
|
||||
|
||||
@@ -642,6 +642,20 @@ export const commands: Commands = {
|
||||
},
|
||||
},
|
||||
},
|
||||
get_os_app_state: {
|
||||
description:
|
||||
'Returns the OS integration state (badge count and registered file handlers) for an installed web app, identified by its manifest ID. (requires flag: --categoryPwa=true)',
|
||||
category: 'Progressive Web Apps',
|
||||
args: {
|
||||
manifestId: {
|
||||
name: 'manifestId',
|
||||
type: 'string',
|
||||
description:
|
||||
'The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
handle_dialog: {
|
||||
description:
|
||||
'If a browser dialog was opened, use this command to handle it',
|
||||
@@ -695,6 +709,56 @@ export const commands: Commands = {
|
||||
},
|
||||
},
|
||||
},
|
||||
install_pwa: {
|
||||
description:
|
||||
'Installs a Progressive Web App (PWA) identified by its manifest ID. This installs through the PWA CDP domain without a user gesture or install dialog. DevTools installs default to browser display mode. (requires flag: --categoryPwa=true)',
|
||||
category: 'Progressive Web Apps',
|
||||
args: {
|
||||
manifestId: {
|
||||
name: 'manifestId',
|
||||
type: 'string',
|
||||
description:
|
||||
'The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.',
|
||||
required: true,
|
||||
},
|
||||
installUrlOrBundleUrl: {
|
||||
name: 'installUrlOrBundleUrl',
|
||||
type: 'string',
|
||||
description:
|
||||
'The location of the app or bundle. For a normal site this is the page URL; for an Isolated Web App it can be a file:// or http(s):// signed web bundle.',
|
||||
required: true,
|
||||
},
|
||||
displayMode: {
|
||||
name: 'displayMode',
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional user display mode preference applied after install. "standalone" opens the app in its own window; "browser" opens it as a tab. Installs via the PWA CDP domain default to "browser" because they do not simulate the install dialog, so pass "standalone" to get an app-window experience.',
|
||||
required: false,
|
||||
enum: ['standalone', 'browser'],
|
||||
},
|
||||
},
|
||||
},
|
||||
launch_pwa: {
|
||||
description:
|
||||
'Launches an installed Progressive Web App using its saved display mode. Optionally opens a specific URL within the same app instead of the default start URL. (requires flag: --categoryPwa=true)',
|
||||
category: 'Progressive Web Apps',
|
||||
args: {
|
||||
manifestId: {
|
||||
name: 'manifestId',
|
||||
type: 'string',
|
||||
description:
|
||||
'The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.',
|
||||
required: true,
|
||||
},
|
||||
url: {
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional URL within the app to open instead of the default start URL.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
lighthouse_audit: {
|
||||
description:
|
||||
'Get Lighthouse score and reports for accessibility, SEO, best practices, and agentic browsing. This excludes performance. For performance audits, run performance_start_trace',
|
||||
@@ -1198,6 +1262,20 @@ export const commands: Commands = {
|
||||
},
|
||||
},
|
||||
},
|
||||
uninstall_pwa: {
|
||||
description:
|
||||
'Uninstalls a Progressive Web App identified by its manifest ID and closes any open app windows. (requires flag: --categoryPwa=true)',
|
||||
category: 'Progressive Web Apps',
|
||||
args: {
|
||||
manifestId: {
|
||||
name: 'manifestId',
|
||||
type: 'string',
|
||||
description:
|
||||
'The manifest ID of the web app: the resolved `id` member of its manifest. If `id` is omitted, it defaults to the resolved `start_url` (e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
upload_file: {
|
||||
description: 'Upload a file through a provided element.',
|
||||
category: 'Input automation',
|
||||
|
||||
@@ -258,6 +258,13 @@ export const cliOptions = {
|
||||
describe:
|
||||
'Set to true to enable third-party developer tools exposed by the inspected page itself',
|
||||
},
|
||||
categoryPwa: {
|
||||
type: 'boolean',
|
||||
hidden: false,
|
||||
conflicts: ['autoConnect', 'browserUrl', 'wsEndpoint'],
|
||||
describe:
|
||||
'Set to true to include tools for automating Progressive Web Apps (install, launch, uninstall, and OS state). This feature is only supported with a pipe connection; autoConnect, browserUrl, and wsEndpoint are not supported.',
|
||||
},
|
||||
performanceCrux: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
|
||||
@@ -371,5 +371,13 @@
|
||||
{
|
||||
"name": "allow_unrestricted_paths",
|
||||
"flagType": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "category_pwa_present",
|
||||
"flagType": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "category_pwa",
|
||||
"flagType": "boolean"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -913,5 +913,53 @@
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_os_app_state",
|
||||
"args": [
|
||||
{
|
||||
"name": "manifest_id_length",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "install_pwa",
|
||||
"args": [
|
||||
{
|
||||
"name": "manifest_id_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "install_url_or_bundle_url_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "display_mode",
|
||||
"argType": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "launch_pwa",
|
||||
"args": [
|
||||
{
|
||||
"name": "manifest_id_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "url_length",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "uninstall_pwa",
|
||||
"args": [
|
||||
{
|
||||
"name": "manifest_id_length",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,12 @@ import type {
|
||||
Dialog,
|
||||
ElementHandle,
|
||||
Extension,
|
||||
GetPWAStateOptions,
|
||||
InstallPWAOptions,
|
||||
LaunchPWAOptions,
|
||||
PWAState,
|
||||
ScreenRecorder,
|
||||
UninstallPWAOptions,
|
||||
Viewport,
|
||||
DevTools,
|
||||
Protocol,
|
||||
@@ -195,6 +200,10 @@ export type SupportedExtensions =
|
||||
*/
|
||||
export type Context = Readonly<{
|
||||
validatePath(filePath?: string): Promise<void>;
|
||||
installPWA(options: InstallPWAOptions): Promise<string>;
|
||||
uninstallPWA(options: UninstallPWAOptions): Promise<void>;
|
||||
launchPWA(options: LaunchPWAOptions): Promise<Page>;
|
||||
getPWAState(options: GetPWAStateOptions): Promise<PWAState>;
|
||||
ensureExtension<Extension extends `.${string}`>(
|
||||
filePath: string,
|
||||
extension: Extension,
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum ToolCategory {
|
||||
THIRD_PARTY = 'experimentalThirdParty',
|
||||
MEMORY = 'memory',
|
||||
WEBMCP = 'experimentalWebmcp',
|
||||
PWA = 'pwa',
|
||||
}
|
||||
|
||||
export const labels = {
|
||||
@@ -28,10 +29,12 @@ export const labels = {
|
||||
[ToolCategory.THIRD_PARTY]: 'Third-party',
|
||||
[ToolCategory.MEMORY]: 'Memory',
|
||||
[ToolCategory.WEBMCP]: 'WebMCP',
|
||||
[ToolCategory.PWA]: 'Progressive Web Apps',
|
||||
};
|
||||
|
||||
export const OFF_BY_DEFAULT_CATEGORIES = [
|
||||
ToolCategory.EXTENSIONS,
|
||||
ToolCategory.THIRD_PARTY,
|
||||
ToolCategory.WEBMCP,
|
||||
ToolCategory.PWA,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
import {zod} from '../third_party/index.js';
|
||||
|
||||
import {ToolCategory} from './categories.js';
|
||||
import {defineTool} from './ToolDefinition.js';
|
||||
|
||||
const manifestIdSchema = zod
|
||||
.string()
|
||||
.describe(
|
||||
'The manifest ID of the web app: the resolved `id` member of its manifest. ' +
|
||||
'If `id` is omitted, it defaults to the resolved `start_url` ' +
|
||||
'(e.g. "https://example.com/"). See https://w3c.github.io/manifest/#id-member.',
|
||||
);
|
||||
|
||||
const displayModeSchema = zod
|
||||
.enum(['standalone', 'browser'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional user display mode preference applied after install. ' +
|
||||
'"standalone" opens the app in its own window; "browser" opens it as a ' +
|
||||
'tab. Installs via the PWA CDP domain default to "browser" because they ' +
|
||||
'do not simulate the install dialog, so pass "standalone" to get an ' +
|
||||
'app-window experience.',
|
||||
);
|
||||
|
||||
export const installPwa = defineTool({
|
||||
name: 'install_pwa',
|
||||
description:
|
||||
'Installs a Progressive Web App (PWA) identified by its manifest ID. ' +
|
||||
'This installs through the PWA CDP domain without a user gesture or install ' +
|
||||
'dialog. DevTools installs default to browser display mode.',
|
||||
annotations: {
|
||||
category: ToolCategory.PWA,
|
||||
readOnlyHint: false,
|
||||
},
|
||||
schema: {
|
||||
manifestId: manifestIdSchema,
|
||||
installUrlOrBundleUrl: zod
|
||||
.string()
|
||||
.describe(
|
||||
'The location of the app or bundle. For a normal site this is the page ' +
|
||||
'URL; for an Isolated Web App it can be a file:// or http(s):// ' +
|
||||
'signed web bundle.',
|
||||
),
|
||||
displayMode: displayModeSchema,
|
||||
},
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: [],
|
||||
handler: async (request, response, context) => {
|
||||
const {manifestId, installUrlOrBundleUrl, displayMode} = request.params;
|
||||
const installUrl = new URL(installUrlOrBundleUrl);
|
||||
if (installUrl.protocol === 'file:') {
|
||||
await context.validatePath(fileURLToPath(installUrl));
|
||||
}
|
||||
await context.installPWA({
|
||||
manifestId,
|
||||
installUrlOrBundleUrl,
|
||||
displayMode,
|
||||
});
|
||||
response.appendResponseLine(
|
||||
`Installed PWA with manifest ID: ${manifestId}`,
|
||||
);
|
||||
if (displayMode) {
|
||||
response.appendResponseLine(`Display mode set to: ${displayMode}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const uninstallPwa = defineTool({
|
||||
name: 'uninstall_pwa',
|
||||
description:
|
||||
'Uninstalls a Progressive Web App identified by its manifest ID and ' +
|
||||
'closes any open app windows.',
|
||||
annotations: {
|
||||
category: ToolCategory.PWA,
|
||||
readOnlyHint: false,
|
||||
},
|
||||
schema: {
|
||||
manifestId: manifestIdSchema,
|
||||
},
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: [],
|
||||
handler: async (request, response, context) => {
|
||||
const {manifestId} = request.params;
|
||||
await context.uninstallPWA({manifestId});
|
||||
response.appendResponseLine(
|
||||
`Uninstalled PWA with manifest ID: ${manifestId}`,
|
||||
);
|
||||
response.setIncludePages(true);
|
||||
},
|
||||
});
|
||||
|
||||
export const launchPwa = defineTool({
|
||||
name: 'launch_pwa',
|
||||
description:
|
||||
'Launches an installed Progressive Web App using its saved display mode. ' +
|
||||
'Optionally opens a specific URL within the same app instead of the default ' +
|
||||
'start URL.',
|
||||
annotations: {
|
||||
category: ToolCategory.PWA,
|
||||
readOnlyHint: false,
|
||||
},
|
||||
schema: {
|
||||
manifestId: manifestIdSchema,
|
||||
url: zod
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional URL within the app to open instead of the default start URL.',
|
||||
),
|
||||
},
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: [],
|
||||
handler: async (request, response, context) => {
|
||||
const {manifestId, url} = request.params;
|
||||
const page = await context.launchPWA({manifestId, url});
|
||||
response.appendResponseLine(
|
||||
`Launched PWA with manifest ID: ${manifestId} (${page.url()})`,
|
||||
);
|
||||
response.setIncludePages(true);
|
||||
},
|
||||
});
|
||||
|
||||
export const getOsAppState = defineTool({
|
||||
name: 'get_os_app_state',
|
||||
description:
|
||||
'Returns the OS integration state (badge count and registered file ' +
|
||||
'handlers) for an installed web app, identified by its manifest ID.',
|
||||
annotations: {
|
||||
category: ToolCategory.PWA,
|
||||
readOnlyHint: true,
|
||||
},
|
||||
schema: {
|
||||
manifestId: manifestIdSchema,
|
||||
},
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: [],
|
||||
handler: async (request, response, context) => {
|
||||
const {manifestId} = request.params;
|
||||
const state = await context.getPWAState({manifestId});
|
||||
response.appendResponseLine(`OS app state for manifest ID: ${manifestId}`);
|
||||
response.appendResponseLine(`Badge count: ${state.badgeCount}`);
|
||||
response.appendResponseLine(
|
||||
`File handlers: ${JSON.stringify(state.fileHandlers)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import * as memoryTools from './memory.js';
|
||||
import * as networkTools from './network.js';
|
||||
import * as pagesTools from './pages.js';
|
||||
import * as performanceTools from './performance.js';
|
||||
import * as pwaTools from './pwa.js';
|
||||
import * as screencastTools from './screencast.js';
|
||||
import * as screenshotTools from './screenshot.js';
|
||||
import * as scriptTools from './script.js';
|
||||
@@ -37,6 +38,7 @@ export const createTools = (args: ParsedArguments) => {
|
||||
...Object.values(networkTools),
|
||||
...Object.values(pagesTools),
|
||||
...Object.values(performanceTools),
|
||||
...Object.values(pwaTools),
|
||||
...Object.values(screencastTools),
|
||||
...Object.values(screenshotTools),
|
||||
...Object.values(scriptTools),
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {
|
||||
installPwa,
|
||||
uninstallPwa,
|
||||
launchPwa,
|
||||
getOsAppState,
|
||||
} from '../../src/tools/pwa.js';
|
||||
import {serverHooks} from '../server.js';
|
||||
import {getTextContent, withMcpContext} from '../utils.js';
|
||||
|
||||
// The `PWA` CDP domain is only available over a pipe connection, which
|
||||
// `withMcpContext` uses by default.
|
||||
const PWA_BROWSER_OPTIONS = {};
|
||||
|
||||
// Served verbatim: the `<link rel="manifest">` must live in the document head,
|
||||
// so this cannot go through the `html` test helper (which re-wraps content in
|
||||
// its own document body).
|
||||
const INDEX_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="alternate manifest" href="manifest.webmanifest" />
|
||||
<title>MCP Test PWA</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MCP Test PWA</h1>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const MANIFEST = JSON.stringify({
|
||||
id: 'pwa/',
|
||||
name: 'MCP Test PWA',
|
||||
short_name: 'MCPPWA',
|
||||
start_url: 'index.html',
|
||||
scope: '/pwa/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#ffffff',
|
||||
icons: [
|
||||
{src: '/pptr.png', sizes: '192x192', type: 'image/png'},
|
||||
{src: '/pptr.png', sizes: '512x512', type: 'image/png'},
|
||||
],
|
||||
});
|
||||
|
||||
describe('pwa', () => {
|
||||
const server = serverHooks();
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
function setupPwaRoutes(): {
|
||||
manifestId: string;
|
||||
startUrl: string;
|
||||
explicitUrl: string;
|
||||
} {
|
||||
server.addRoute('/pwa/index.html', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.statusCode = 200;
|
||||
res.end(INDEX_HTML);
|
||||
});
|
||||
server.addRoute('/pwa/manifest.webmanifest', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.statusCode = 200;
|
||||
res.end(MANIFEST);
|
||||
});
|
||||
server.addHtmlRoute(
|
||||
'/pwa/secondary.html',
|
||||
'<h1>Explicit PWA launch URL</h1>',
|
||||
);
|
||||
return {
|
||||
manifestId: `${server.baseUrl}/pwa/`,
|
||||
startUrl: `${server.baseUrl}/pwa/index.html`,
|
||||
explicitUrl: `${server.baseUrl}/pwa/secondary.html`,
|
||||
};
|
||||
}
|
||||
|
||||
it('installs a PWA, reads its OS state, and uninstalls it', async () => {
|
||||
const {manifestId, startUrl} = setupPwaRoutes();
|
||||
await withMcpContext(
|
||||
async (response, context) => {
|
||||
await installPwa.handler(
|
||||
{params: {manifestId, installUrlOrBundleUrl: startUrl}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
assert.ok(
|
||||
response.responseLines.some(l => l.includes(manifestId)),
|
||||
'install response should reference the manifest ID',
|
||||
);
|
||||
|
||||
response.resetResponseLineForTesting();
|
||||
await getOsAppState.handler({params: {manifestId}}, response, context);
|
||||
const stateOutput = response.responseLines.join('\n');
|
||||
assert.ok(
|
||||
stateOutput.includes('Badge count: 0'),
|
||||
`expected a badge count in OS state, got: ${stateOutput}`,
|
||||
);
|
||||
assert.ok(
|
||||
stateOutput.includes('File handlers: []'),
|
||||
`expected file handlers in OS state, got: ${stateOutput}`,
|
||||
);
|
||||
|
||||
const includePages = sinon.spy(response, 'setIncludePages');
|
||||
await uninstallPwa.handler({params: {manifestId}}, response, context);
|
||||
assert.ok(
|
||||
includePages.calledOnce,
|
||||
'uninstall should refresh the page list after closing app windows',
|
||||
);
|
||||
|
||||
// Querying OS state after uninstall should reject.
|
||||
await assert.rejects(
|
||||
getOsAppState.handler({params: {manifestId}}, response, context),
|
||||
);
|
||||
},
|
||||
PWA_BROWSER_OPTIONS,
|
||||
{categoryPwa: true},
|
||||
);
|
||||
});
|
||||
|
||||
it('validates file bundle paths before installation', async () => {
|
||||
await withMcpContext(
|
||||
async (response, context) => {
|
||||
const manifestId = 'isolated-app://example/';
|
||||
const filePath = path.resolve('test-app.swbn');
|
||||
const installUrlOrBundleUrl = pathToFileURL(filePath).href;
|
||||
const validatePath = sinon.stub(context, 'validatePath').resolves();
|
||||
const install = sinon.stub(context, 'installPWA').resolves(manifestId);
|
||||
|
||||
await installPwa.handler(
|
||||
{params: {manifestId, installUrlOrBundleUrl}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
assert.ok(validatePath.calledOnceWithExactly(filePath));
|
||||
assert.ok(install.calledOnce);
|
||||
|
||||
validatePath.reset();
|
||||
validatePath.rejects(new Error('Path is outside configured roots'));
|
||||
install.resetHistory();
|
||||
await assert.rejects(
|
||||
installPwa.handler(
|
||||
{params: {manifestId, installUrlOrBundleUrl}},
|
||||
response,
|
||||
context,
|
||||
),
|
||||
/Path is outside configured roots/,
|
||||
);
|
||||
assert.ok(install.notCalled);
|
||||
},
|
||||
PWA_BROWSER_OPTIONS,
|
||||
{categoryPwa: true},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not require a selected page for browser-scoped operations', async () => {
|
||||
const {manifestId, startUrl} = setupPwaRoutes();
|
||||
await withMcpContext(
|
||||
async (response, context) => {
|
||||
const page = context.getSelectedMcpPage().pptrPage;
|
||||
sinon
|
||||
.stub(context, 'getSelectedMcpPage')
|
||||
.throws(new Error('No page selected'));
|
||||
const install = sinon.stub(context, 'installPWA').resolves(manifestId);
|
||||
const launch = sinon.stub(context, 'launchPWA').resolves(page);
|
||||
const getState = sinon
|
||||
.stub(context, 'getPWAState')
|
||||
.resolves({badgeCount: 0, fileHandlers: []});
|
||||
const uninstall = sinon.stub(context, 'uninstallPWA').resolves();
|
||||
|
||||
await installPwa.handler(
|
||||
{params: {manifestId, installUrlOrBundleUrl: startUrl}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
await launchPwa.handler({params: {manifestId}}, response, context);
|
||||
await getOsAppState.handler({params: {manifestId}}, response, context);
|
||||
await uninstallPwa.handler({params: {manifestId}}, response, context);
|
||||
|
||||
assert.ok(install.calledOnce);
|
||||
assert.ok(launch.calledOnce);
|
||||
assert.ok(getState.calledOnce);
|
||||
assert.ok(uninstall.calledOnce);
|
||||
},
|
||||
PWA_BROWSER_OPTIONS,
|
||||
{categoryPwa: true},
|
||||
);
|
||||
});
|
||||
|
||||
it('launches an installed PWA in a standalone window', async () => {
|
||||
const {manifestId, startUrl} = setupPwaRoutes();
|
||||
await withMcpContext(
|
||||
async (response, context) => {
|
||||
await installPwa.handler(
|
||||
{
|
||||
params: {
|
||||
manifestId,
|
||||
installUrlOrBundleUrl: startUrl,
|
||||
displayMode: 'standalone',
|
||||
},
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
response.resetResponseLineForTesting();
|
||||
await launchPwa.handler({params: {manifestId}}, response, context);
|
||||
assert.ok(
|
||||
response.responseLines.some(l => l.includes(startUrl)),
|
||||
`launch response should reference the app url, got: ${response.responseLines.join('\n')}`,
|
||||
);
|
||||
|
||||
// The launched app window is a real page reachable via the browser,
|
||||
// running in standalone display mode.
|
||||
const appPage = context.browser
|
||||
.targets()
|
||||
.find(t => t.url() === startUrl);
|
||||
assert.ok(appPage, 'the launched app page should be a browser target');
|
||||
const page = await appPage.page();
|
||||
assert.ok(page, 'the launched app target should resolve to a Page');
|
||||
const isStandalone = await page.evaluate(() => {
|
||||
return matchMedia('(display-mode: standalone)').matches;
|
||||
});
|
||||
assert.strictEqual(
|
||||
isStandalone,
|
||||
true,
|
||||
'the launched app should be in standalone display mode',
|
||||
);
|
||||
|
||||
await uninstallPwa.handler({params: {manifestId}}, response, context);
|
||||
},
|
||||
PWA_BROWSER_OPTIONS,
|
||||
{categoryPwa: true},
|
||||
);
|
||||
});
|
||||
|
||||
it('launches an installed PWA at an explicit URL', async () => {
|
||||
const {manifestId, startUrl, explicitUrl} = setupPwaRoutes();
|
||||
await withMcpContext(
|
||||
async (response, context) => {
|
||||
await installPwa.handler(
|
||||
{
|
||||
params: {
|
||||
manifestId,
|
||||
installUrlOrBundleUrl: startUrl,
|
||||
displayMode: 'standalone',
|
||||
},
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
response.resetResponseLineForTesting();
|
||||
await launchPwa.handler(
|
||||
{params: {manifestId, url: explicitUrl}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
assert.ok(
|
||||
response.responseLines.some(line => {
|
||||
return line.includes(explicitUrl);
|
||||
}),
|
||||
`launch response should reference ${explicitUrl}`,
|
||||
);
|
||||
|
||||
const appTarget = context.browser.targets().find(target => {
|
||||
return target.url() === explicitUrl;
|
||||
});
|
||||
assert.ok(appTarget, 'the explicit launch target should exist');
|
||||
|
||||
await uninstallPwa.handler({params: {manifestId}}, response, context);
|
||||
},
|
||||
PWA_BROWSER_OPTIONS,
|
||||
{categoryPwa: true},
|
||||
);
|
||||
});
|
||||
|
||||
it('selects a surviving page after uninstall closes the selected app', async () => {
|
||||
const {manifestId, startUrl} = setupPwaRoutes();
|
||||
await withMcpContext(
|
||||
async (response, context) => {
|
||||
const originalPage = context.getSelectedMcpPage();
|
||||
await installPwa.handler(
|
||||
{
|
||||
params: {
|
||||
manifestId,
|
||||
installUrlOrBundleUrl: startUrl,
|
||||
displayMode: 'standalone',
|
||||
},
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
await launchPwa.handler({params: {manifestId}}, response, context);
|
||||
await context.createPagesSnapshot();
|
||||
const appPage = context.getPages().find(page => {
|
||||
return page.pptrPage.url() === startUrl;
|
||||
});
|
||||
assert.ok(appPage, 'the launched app page should be listed');
|
||||
context.selectPage(appPage);
|
||||
|
||||
response.resetResponseLineForTesting();
|
||||
await uninstallPwa.handler({params: {manifestId}}, response, context);
|
||||
const result = await response.handle(context);
|
||||
|
||||
assert.strictEqual(context.getSelectedMcpPage(), originalPage);
|
||||
assert.match(
|
||||
getTextContent(result.content[0]),
|
||||
/previously selected page was closed/,
|
||||
);
|
||||
},
|
||||
PWA_BROWSER_OPTIONS,
|
||||
{categoryPwa: true},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user