1301 lines
41 KiB
TypeScript
1301 lines
41 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 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 type {Dialog} from 'puppeteer-core';
|
|
import sinon from 'sinon';
|
|
|
|
import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
|
|
import {
|
|
listPages,
|
|
newPage,
|
|
closePage,
|
|
selectPage,
|
|
navigatePage,
|
|
resizePage,
|
|
handleDialog,
|
|
getTabId,
|
|
} from '../../src/tools/pages.js';
|
|
import {assertNoServiceWorkerReported, html, withMcpContext} from '../utils.js';
|
|
|
|
const EXTENSION_SW_PATH = path.join(
|
|
import.meta.dirname,
|
|
'../../../tests/tools/fixtures/extension-sw',
|
|
);
|
|
const EXTENSION_PATH = path.join(
|
|
import.meta.dirname,
|
|
'../../../tests/tools/fixtures/extension',
|
|
);
|
|
const EXTENSION_SIDE_PANEL_PATH = path.join(
|
|
import.meta.dirname,
|
|
'../../../tests/tools/fixtures/extension-side-panel',
|
|
);
|
|
|
|
describe('pages', () => {
|
|
afterEach(() => {
|
|
sinon.restore();
|
|
});
|
|
|
|
describe('list_pages', () => {
|
|
it('list pages', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await listPages().handler({params: {}}, response, context);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('list pages after selected page is closed', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
// Create a second page and select it.
|
|
const page2 = await context.newPage();
|
|
assert.strictEqual(context.getSelectedMcpPage(), page2);
|
|
|
|
// Close the selected page via puppeteer (simulating external close).
|
|
await page2.pptrPage.close();
|
|
|
|
// list_pages should still work even though the selected page is gone.
|
|
await listPages().handler({params: {}}, response, context);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it(`list pages for extension pages with --category-extensions`, async t => {
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
const extensionId = await context.installExtension(EXTENSION_PATH);
|
|
|
|
assert.ok(extensionId);
|
|
|
|
await context.triggerExtensionAction(extensionId);
|
|
|
|
const _popupTarget = await context.browser.waitForTarget(
|
|
t => t.type() === 'page' && t.url().includes('chrome-extension://'),
|
|
);
|
|
|
|
response.resetResponseLineForTesting();
|
|
const listPageDef = listPages({
|
|
categoryExtensions: true,
|
|
} as ParsedArguments);
|
|
await listPageDef.handler({params: {}}, response, context);
|
|
|
|
const result = await response.handle(listPageDef.name, context);
|
|
const textContent = result.content.find(c => c.type === 'text') as {
|
|
type: 'text';
|
|
text: string;
|
|
};
|
|
assert.ok(textContent);
|
|
|
|
const text = textContent.text.replaceAll(
|
|
extensionId,
|
|
'<extension-id>',
|
|
);
|
|
t.assert.snapshot(text);
|
|
await context.uninstallExtension(extensionId);
|
|
},
|
|
{},
|
|
{
|
|
categoryExtensions: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
for (const categoryExtensions of [true, false]) {
|
|
it(`list pages for extension service workers ${categoryExtensions ? 'with' : 'without'} --category-extensions`, async t => {
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
const extensionId =
|
|
await context.installExtension(EXTENSION_SW_PATH);
|
|
assert.ok(extensionId);
|
|
|
|
const swTarget = await context.browser.waitForTarget(
|
|
target =>
|
|
target.type() === 'service_worker' &&
|
|
target.url().includes('chrome-extension://'),
|
|
);
|
|
const swUrl = swTarget.url();
|
|
|
|
const listPageDef = listPages({
|
|
categoryExtensions,
|
|
} as ParsedArguments);
|
|
await listPageDef.handler({params: {}}, response, context);
|
|
|
|
const result = await response.handle(listPageDef.name, context);
|
|
const textContent = result.content.find(c => c.type === 'text') as {
|
|
type: 'text';
|
|
text: string;
|
|
};
|
|
assert.ok(textContent);
|
|
|
|
if (categoryExtensions) {
|
|
const structured = result.structuredContent as {
|
|
extensionServiceWorkers: Array<{url: string}>;
|
|
};
|
|
assert.deepStrictEqual(
|
|
structured.extensionServiceWorkers.map(sw => sw.url),
|
|
[swUrl],
|
|
);
|
|
}
|
|
|
|
const text = textContent.text.replaceAll(
|
|
extensionId,
|
|
'<extension-id>',
|
|
);
|
|
t.assert.snapshot(text);
|
|
await context.uninstallExtension(extensionId);
|
|
const targets = context.browser.targets();
|
|
assertNoServiceWorkerReported(targets, extensionId);
|
|
},
|
|
{},
|
|
{
|
|
categoryExtensions,
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
it('list pages for side panels with --category-extensions', async t => {
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
const extensionId = await context.installExtension(
|
|
EXTENSION_SIDE_PANEL_PATH,
|
|
);
|
|
|
|
assert.ok(extensionId);
|
|
|
|
const sidePanelPage = await context.newPage();
|
|
await sidePanelPage.pptrPage.goto(
|
|
`chrome-extension://${extensionId}/sidepanel.html`,
|
|
);
|
|
|
|
await context.getSelectedMcpPage().waitForTextOnPage(['Side Panel']);
|
|
|
|
// Wait for service worker used in the snapshot.
|
|
await context.browser.waitForTarget(
|
|
target => target.type() === 'service_worker',
|
|
);
|
|
|
|
const listPageDef = listPages({
|
|
categoryExtensions: true,
|
|
} as ParsedArguments);
|
|
await listPageDef.handler({params: {}}, response, context);
|
|
|
|
const result = await response.handle(listPageDef.name, context);
|
|
const textContent = result.content.find(c => c.type === 'text') as {
|
|
type: 'text';
|
|
text: string;
|
|
};
|
|
assert.ok(textContent);
|
|
|
|
const text = textContent.text.replaceAll(
|
|
extensionId,
|
|
'<extension-id>',
|
|
);
|
|
t.assert.snapshot(text);
|
|
await context.uninstallExtension(extensionId);
|
|
const targets = context.browser.targets();
|
|
assertNoServiceWorkerReported(targets, extensionId);
|
|
},
|
|
{},
|
|
{
|
|
categoryExtensions: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('when dialog is open', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
|
|
const dialogPromise = new Promise<Dialog>(resolve => {
|
|
page.on('dialog', dialog => {
|
|
resolve(dialog);
|
|
});
|
|
});
|
|
|
|
const evalPromise = page.evaluate(() => {
|
|
alert('test dialog');
|
|
});
|
|
const dialog = await dialogPromise;
|
|
|
|
await listPages().handler({params: {}}, response, context);
|
|
|
|
const result = await response.handle('list_pages', context);
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
await dialog.dismiss();
|
|
await evalPromise;
|
|
});
|
|
});
|
|
});
|
|
describe('new_page', () => {
|
|
it('create a page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
assert.strictEqual(
|
|
context.getPageById(1),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank'}},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
context.getPageById(2),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('create a page in the background', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const originalPage = context.getPageById(1);
|
|
assert.strictEqual(originalPage, context.getSelectedMcpPage());
|
|
// Ensure original page has focus
|
|
await originalPage.pptrPage.bringToFront();
|
|
assert.strictEqual(
|
|
await originalPage.pptrPage.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', background: true}},
|
|
response,
|
|
context,
|
|
);
|
|
// New page should be selected but original should retain focus
|
|
assert.strictEqual(
|
|
context.getPageById(2),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.strictEqual(
|
|
await originalPage.pptrPage.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
});
|
|
describe('new_page with isolatedContext', () => {
|
|
it('creates a page in an isolated context', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
|
|
response,
|
|
context,
|
|
);
|
|
const mcpPage = context.getSelectedMcpPage();
|
|
assert.strictEqual(mcpPage.isolatedContextName, 'session-a');
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
|
|
it('reuses the same context for the same isolatedContext name', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
|
|
response,
|
|
context,
|
|
);
|
|
const mcpPage1 = context.getSelectedMcpPage();
|
|
const page1 = mcpPage1.pptrPage;
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
|
|
response,
|
|
context,
|
|
);
|
|
const mcpPage2 = context.getSelectedMcpPage();
|
|
const page2 = mcpPage2.pptrPage;
|
|
assert.notStrictEqual(page1, page2);
|
|
assert.strictEqual(mcpPage1.isolatedContextName, 'session-a');
|
|
assert.strictEqual(mcpPage2.isolatedContextName, 'session-a');
|
|
assert.strictEqual(page1.browserContext(), page2.browserContext());
|
|
});
|
|
});
|
|
|
|
it('creates separate contexts for different isolatedContext names', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
|
|
response,
|
|
context,
|
|
);
|
|
const mcpPageA = context.getSelectedMcpPage();
|
|
const pageA = mcpPageA.pptrPage;
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'session-b'}},
|
|
response,
|
|
context,
|
|
);
|
|
const mcpPageB = context.getSelectedMcpPage();
|
|
const pageB = mcpPageB.pptrPage;
|
|
assert.strictEqual(mcpPageA.isolatedContextName, 'session-a');
|
|
assert.strictEqual(mcpPageB.isolatedContextName, 'session-b');
|
|
assert.notStrictEqual(pageA.browserContext(), pageB.browserContext());
|
|
});
|
|
});
|
|
|
|
it('includes isolatedContext in page listing', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
|
|
response,
|
|
context,
|
|
);
|
|
const result = await response.handle('new_page', context);
|
|
const pages = (
|
|
result.structuredContent as {pages: Array<{isolatedContext?: string}>}
|
|
).pages;
|
|
const isolatedPage = pages.find(p => p.isolatedContext === 'session-a');
|
|
assert.ok(isolatedPage);
|
|
});
|
|
});
|
|
|
|
it('does not set isolatedContext for pages in the default context', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const mcpPage = context.getSelectedMcpPage();
|
|
assert.strictEqual(mcpPage.isolatedContextName, undefined);
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank'}},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
context.getSelectedMcpPage().isolatedContextName,
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('closes an isolated page without errors', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
|
|
response,
|
|
context,
|
|
);
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const pageId = context.getSelectedMcpPage().id;
|
|
assert.ok(!page.isClosed());
|
|
await closePage.handler({params: {pageId}}, response, context);
|
|
assert.ok(page.isClosed());
|
|
});
|
|
});
|
|
|
|
it('when dialog is open', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
|
|
const dialogPromise = new Promise<Dialog>(resolve => {
|
|
page.on('dialog', dialog => {
|
|
resolve(dialog);
|
|
});
|
|
});
|
|
|
|
const evalPromise = page.evaluate(() => {
|
|
alert('test dialog');
|
|
});
|
|
const dialog = await dialogPromise;
|
|
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank'}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const result = await response.handle('new_page', context);
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
await dialog.dismiss();
|
|
await evalPromise;
|
|
});
|
|
});
|
|
});
|
|
|
|
it('navigate_page targets the pageId page, not the global selection', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await newPage().handler(
|
|
{
|
|
params: {
|
|
url: 'data:text/html,<h1>Initial</h1>',
|
|
isolatedContext: 'nav-ctx',
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const isolatedPage = context.getSelectedMcpPage();
|
|
|
|
// Switch global selection back to the default page.
|
|
await selectPage.handler({params: {pageId: 1}}, response, context);
|
|
assert.notStrictEqual(context.getSelectedMcpPage(), isolatedPage);
|
|
|
|
// Navigate using page; should target the isolated page.
|
|
await navigatePage().handler(
|
|
{
|
|
params: {
|
|
url: 'data:text/html,<h1>Navigated</h1>',
|
|
},
|
|
page: isolatedPage,
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
// Verify the isolated page was navigated.
|
|
const content = await isolatedPage.pptrPage.evaluate(
|
|
() => document.querySelector('h1')?.textContent,
|
|
);
|
|
assert.strictEqual(content, 'Navigated');
|
|
|
|
// Verify the default page was NOT affected.
|
|
const defaultContent = await context
|
|
.getSelectedMcpPage()
|
|
.pptrPage.evaluate(() => document.querySelector('h1')?.textContent);
|
|
assert.notStrictEqual(defaultContent, 'Navigated');
|
|
});
|
|
});
|
|
|
|
describe('close_page', () => {
|
|
it('closes a page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = await context.newPage();
|
|
assert.strictEqual(
|
|
context.getPageById(2),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.strictEqual(context.getPageById(2), page);
|
|
await closePage.handler({params: {pageId: 2}}, response, context);
|
|
assert.ok(page.pptrPage.isClosed());
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('cannot close the last page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await closePage.handler({params: {pageId: 1}}, response, context);
|
|
assert.deepStrictEqual(
|
|
response.responseLines[0],
|
|
`The last open page cannot be closed. It is fine to keep it open.`,
|
|
);
|
|
assert.ok(response.includePages);
|
|
assert.ok(!page.isClosed());
|
|
});
|
|
});
|
|
|
|
it('when dialog is open', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = await context.newPage();
|
|
assert.strictEqual(
|
|
context.getPageById(2),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.strictEqual(context.getPageById(2), page);
|
|
|
|
const dialogPromise = new Promise<void>(resolve => {
|
|
page.pptrPage.on('dialog', () => resolve());
|
|
});
|
|
|
|
page.pptrPage
|
|
.evaluate(() => {
|
|
alert('test dialog');
|
|
})
|
|
.catch(() => {
|
|
// Ignore TargetCloseError when page is closed with open dialog
|
|
});
|
|
await dialogPromise;
|
|
|
|
await closePage.handler({params: {pageId: 2}}, response, context);
|
|
|
|
const result = await response.handle('close_page', context);
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
});
|
|
});
|
|
});
|
|
describe('select_page', () => {
|
|
it('selects a page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await context.newPage();
|
|
assert.strictEqual(
|
|
context.getPageById(2),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await selectPage.handler({params: {pageId: 1}}, response, context);
|
|
assert.strictEqual(
|
|
context.getPageById(1),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('selects a page and keeps it focused in the background', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await context.newPage();
|
|
assert.strictEqual(
|
|
context.getPageById(2),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.strictEqual(
|
|
await context
|
|
.getPageById(1)
|
|
.pptrPage.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
await selectPage.handler({params: {pageId: 1}}, response, context);
|
|
assert.strictEqual(
|
|
context.getPageById(1),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.strictEqual(
|
|
await context
|
|
.getPageById(1)
|
|
.pptrPage.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('preserves focus across different browser contexts', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
// Create pages in separate isolated contexts.
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'ctx-a'}},
|
|
response,
|
|
context,
|
|
);
|
|
const pageA = context.getSelectedMcpPage().pptrPage;
|
|
const pageAId = context.getSelectedMcpPage().id;
|
|
|
|
await newPage().handler(
|
|
{params: {url: 'about:blank', isolatedContext: 'ctx-b'}},
|
|
response,
|
|
context,
|
|
);
|
|
const pageB = context.getSelectedMcpPage().pptrPage;
|
|
|
|
// Selecting pageB (ctx-b) should not defocus pageA (ctx-a).
|
|
assert.strictEqual(
|
|
await pageA.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
assert.strictEqual(
|
|
await pageB.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
|
|
// Switching back to pageA should preserve pageB's focus.
|
|
await selectPage.handler(
|
|
{params: {pageId: pageAId}},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
await pageA.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
assert.strictEqual(
|
|
await pageB.evaluate(() => document.hasFocus()),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('when dialog is open', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
|
|
const dialogPromise = new Promise<Dialog>(resolve => {
|
|
page.on('dialog', dialog => {
|
|
resolve(dialog);
|
|
});
|
|
});
|
|
|
|
const evalPromise = page.evaluate(() => {
|
|
alert('test dialog');
|
|
});
|
|
const dialog = await dialogPromise;
|
|
|
|
await selectPage.handler({params: {pageId: 1}}, response, context);
|
|
|
|
const result = await response.handle('select_page', context);
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
await dialog.dismiss();
|
|
await evalPromise;
|
|
});
|
|
});
|
|
});
|
|
describe('navigate_page', () => {
|
|
it('navigates to correct page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await navigatePage().handler(
|
|
{
|
|
params: {url: 'data:text/html,<div>Hello MCP</div>'},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
assert.equal(
|
|
await page.evaluate(() => document.querySelector('div')?.textContent),
|
|
'Hello MCP',
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
|
|
it('throws an error if the page was closed not by the MCP server', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = await context.newPage();
|
|
assert.strictEqual(
|
|
context.getPageById(2),
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
assert.strictEqual(context.getPageById(2), page);
|
|
|
|
await page.pptrPage.close();
|
|
|
|
try {
|
|
await navigatePage().handler(
|
|
{
|
|
params: {url: 'data:text/html,<div>Hello MCP</div>'},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.fail('should not reach here');
|
|
} catch (err) {
|
|
assert.strictEqual(
|
|
err.message,
|
|
'The selected page has been closed. Call list_pages to see open pages.',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('respects the timeout parameter', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const stub = sinon.stub(page, 'waitForNavigation').resolves(null);
|
|
|
|
try {
|
|
await navigatePage().handler(
|
|
{
|
|
params: {
|
|
url: 'about:blank',
|
|
timeout: 12345,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
} finally {
|
|
stub.restore();
|
|
}
|
|
|
|
assert.strictEqual(
|
|
stub.firstCall.args[0]?.timeout,
|
|
12345,
|
|
'The timeout parameter should be passed to waitForNavigation',
|
|
);
|
|
});
|
|
});
|
|
it('go back', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.goto('data:text/html,<div>Hello MCP</div>');
|
|
await navigatePage().handler(
|
|
{params: {type: 'back'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(
|
|
await page.evaluate(() => document.location.href),
|
|
'about:blank',
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('go forward', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.goto('data:text/html,<div>Hello MCP</div>');
|
|
await page.goBack();
|
|
await navigatePage().handler(
|
|
{params: {type: 'forward'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(
|
|
await page.evaluate(() => document.querySelector('div')?.textContent),
|
|
'Hello MCP',
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('reload', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.goto('data:text/html,<div>Hello MCP</div>');
|
|
await navigatePage().handler(
|
|
{params: {type: 'reload'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.equal(
|
|
await page.evaluate(() => document.location.href),
|
|
'data:text/html,<div>Hello MCP</div>',
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
|
|
it('reload with accpeting the beforeunload dialog', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.setContent(
|
|
html` <script>
|
|
window.addEventListener('beforeunload', e => {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
});
|
|
</script>`,
|
|
);
|
|
|
|
await navigatePage().handler(
|
|
{params: {type: 'reload'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.strictEqual(context.getSelectedMcpPage().getDialog(), undefined);
|
|
assert.ok(response.includePages);
|
|
assert.strictEqual(
|
|
response.responseLines.join('\n'),
|
|
'Successfully reloaded the page.\nAccepted a beforeunload dialog.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('reload with declining the beforeunload dialog', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.setContent(
|
|
html` <script>
|
|
window.addEventListener('beforeunload', e => {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
});
|
|
</script>`,
|
|
);
|
|
|
|
await navigatePage().handler(
|
|
{
|
|
params: {
|
|
type: 'reload',
|
|
handleBeforeUnload: 'dismiss',
|
|
timeout: 500,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.strictEqual(context.getSelectedMcpPage().getDialog(), undefined);
|
|
assert.ok(response.includePages);
|
|
assert.strictEqual(
|
|
response.responseLines.join('\n'),
|
|
'Unable to reload the selected page: Navigation timeout of 500 ms exceeded.\nDismissed a beforeunload dialog.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('go forward with error', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await navigatePage().handler(
|
|
{params: {type: 'forward'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.ok(
|
|
response.responseLines
|
|
.at(0)
|
|
?.startsWith('Unable to navigate forward in the selected page:'),
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('go back with error', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await navigatePage().handler(
|
|
{params: {type: 'back'}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.ok(
|
|
response.responseLines
|
|
.at(0)
|
|
?.startsWith('Unable to navigate back in the selected page:'),
|
|
);
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
it('navigates to correct page with initScript', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await navigatePage().handler(
|
|
{
|
|
params: {
|
|
url: 'data:text/html,<div>Hello MCP</div>',
|
|
initScript: 'window.initScript = "completed"',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
|
|
// wait for up to 1s for the global variable to set by the initScript to exist
|
|
await page.waitForFunction("window.initScript==='completed'", {
|
|
timeout: 1000,
|
|
});
|
|
|
|
assert.ok(response.includePages);
|
|
});
|
|
});
|
|
|
|
it('when dialog is open', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const dialogPromise = new Promise<void>(resolve => {
|
|
page.on('dialog', () => resolve());
|
|
});
|
|
|
|
page
|
|
.evaluate(() => {
|
|
alert('test dialog');
|
|
})
|
|
.catch(() => {
|
|
// Ignore error when navigation destroys the execution context
|
|
});
|
|
await dialogPromise;
|
|
|
|
await navigatePage().handler(
|
|
{
|
|
params: {url: 'data:text/html,<div>Navigated</div>'},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const result = await response.handle('navigate_page', context);
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
});
|
|
});
|
|
});
|
|
describe('resize', () => {
|
|
it('resize the page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const resizePromise = page.evaluate(() => {
|
|
return new Promise(resolve => {
|
|
window.addEventListener('resize', resolve, {once: true});
|
|
});
|
|
});
|
|
await resizePage.handler(
|
|
{
|
|
params: {width: 700, height: 500},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
await resizePromise;
|
|
await page.waitForFunction(
|
|
() => window.innerWidth === 700 && window.innerHeight === 500,
|
|
);
|
|
const dimensions = await page.evaluate(() => {
|
|
return [window.innerWidth, window.innerHeight];
|
|
});
|
|
assert.deepStrictEqual(dimensions, [700, 500]);
|
|
});
|
|
});
|
|
|
|
it('resize when window state is normal', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const browser = page.browser();
|
|
const windowId = await page.windowId();
|
|
await browser.setWindowBounds(windowId, {windowState: 'normal'});
|
|
|
|
const {windowState} = await browser.getWindowBounds(windowId);
|
|
assert.strictEqual(windowState, 'normal');
|
|
|
|
const resizePromise = page.evaluate(() => {
|
|
return new Promise(resolve => {
|
|
window.addEventListener('resize', resolve, {once: true});
|
|
});
|
|
});
|
|
await resizePage.handler(
|
|
{
|
|
params: {width: 650, height: 450},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
await resizePromise;
|
|
await page.waitForFunction(
|
|
() => window.innerWidth === 650 && window.innerHeight === 450,
|
|
);
|
|
const dimensions = await page.evaluate(() => {
|
|
return [window.innerWidth, window.innerHeight];
|
|
});
|
|
assert.deepStrictEqual(dimensions, [650, 450]);
|
|
});
|
|
});
|
|
|
|
it('resize when window state is minimized', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const browser = page.browser();
|
|
const windowId = await page.windowId();
|
|
await browser.setWindowBounds(windowId, {windowState: 'minimized'});
|
|
|
|
const {windowState} = await browser.getWindowBounds(windowId);
|
|
assert.strictEqual(windowState, 'minimized');
|
|
|
|
const resizePromise = page.evaluate(() => {
|
|
return new Promise(resolve => {
|
|
window.addEventListener('resize', resolve, {once: true});
|
|
});
|
|
});
|
|
await resizePage.handler(
|
|
{
|
|
params: {width: 750, height: 550},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
await resizePromise;
|
|
await page.waitForFunction(
|
|
() => window.innerWidth === 750 && window.innerHeight === 550,
|
|
);
|
|
const dimensions = await page.evaluate(() => {
|
|
return [window.innerWidth, window.innerHeight];
|
|
});
|
|
assert.deepStrictEqual(dimensions, [750, 550]);
|
|
});
|
|
});
|
|
|
|
it('resize when window state is maximized', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const browser = page.browser();
|
|
const windowId = await page.windowId();
|
|
await browser.setWindowBounds(windowId, {windowState: 'maximized'});
|
|
|
|
const {windowState} = await browser.getWindowBounds(windowId);
|
|
assert.strictEqual(windowState, 'maximized');
|
|
|
|
const resizePromise = page.evaluate(() => {
|
|
return new Promise(resolve => {
|
|
window.addEventListener('resize', resolve, {once: true});
|
|
});
|
|
});
|
|
await resizePage.handler(
|
|
{
|
|
params: {width: 725, height: 525},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
await resizePromise;
|
|
await page.waitForFunction(
|
|
() => window.innerWidth === 725 && window.innerHeight === 525,
|
|
);
|
|
const dimensions = await page.evaluate(() => {
|
|
return [window.innerWidth, window.innerHeight];
|
|
});
|
|
assert.deepStrictEqual(dimensions, [725, 525]);
|
|
});
|
|
});
|
|
|
|
it('resize when window state is fullscreen', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const browser = page.browser();
|
|
const windowId = await page.windowId();
|
|
await browser.setWindowBounds(windowId, {windowState: 'fullscreen'});
|
|
|
|
const {windowState} = await browser.getWindowBounds(windowId);
|
|
assert.strictEqual(windowState, 'fullscreen');
|
|
|
|
const resizePromise = page.evaluate(() => {
|
|
return new Promise(resolve => {
|
|
window.addEventListener('resize', resolve, {once: true});
|
|
});
|
|
});
|
|
await resizePage.handler(
|
|
{
|
|
params: {width: 850, height: 650},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
await resizePromise;
|
|
await page.waitForFunction(
|
|
() => window.innerWidth === 850 && window.innerHeight === 650,
|
|
);
|
|
const dimensions = await page.evaluate(() => {
|
|
return [window.innerWidth, window.innerHeight];
|
|
});
|
|
assert.deepStrictEqual(dimensions, [850, 650]);
|
|
});
|
|
});
|
|
|
|
it('when dialog is open', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const dialogPromise = new Promise<Dialog>(resolve => {
|
|
page.on('dialog', dialog => {
|
|
resolve(dialog);
|
|
});
|
|
});
|
|
|
|
const evalPromise = page.evaluate(() => {
|
|
alert('test dialog');
|
|
});
|
|
const dialog = await dialogPromise;
|
|
|
|
await resizePage.handler(
|
|
{
|
|
params: {width: 1600, height: 1400},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const result = await response.handle('resize_page', context);
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
await dialog.dismiss();
|
|
await evalPromise;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('dialogs', () => {
|
|
it('can accept dialogs', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const dialogPromise = new Promise<void>(resolve => {
|
|
page.on('dialog', () => {
|
|
resolve();
|
|
});
|
|
});
|
|
const evalPromise = page.evaluate(() => {
|
|
alert('test');
|
|
});
|
|
await dialogPromise;
|
|
await handleDialog.handler(
|
|
{
|
|
params: {
|
|
action: 'accept',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(context.getSelectedMcpPage().getDialog(), undefined);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully accepted the dialog',
|
|
);
|
|
await evalPromise;
|
|
});
|
|
});
|
|
it('can dismiss dialogs', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const dialogPromise = new Promise<void>(resolve => {
|
|
page.on('dialog', () => {
|
|
resolve();
|
|
});
|
|
});
|
|
const evalPromise = page.evaluate(() => {
|
|
alert('test');
|
|
});
|
|
await dialogPromise;
|
|
await handleDialog.handler(
|
|
{
|
|
params: {
|
|
action: 'dismiss',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(context.getSelectedMcpPage().getDialog(), undefined);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully dismissed the dialog',
|
|
);
|
|
await evalPromise;
|
|
});
|
|
});
|
|
it('can dismiss already dismissed dialog dialogs', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
const dialogPromise = new Promise<Dialog>(resolve => {
|
|
page.on('dialog', dialog => {
|
|
resolve(dialog);
|
|
});
|
|
});
|
|
const evalPromise = page.evaluate(() => {
|
|
alert('test');
|
|
});
|
|
const dialog = await dialogPromise;
|
|
await dialog.dismiss();
|
|
await handleDialog.handler(
|
|
{
|
|
params: {
|
|
action: 'dismiss',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(context.getSelectedMcpPage().getDialog(), undefined);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully dismissed the dialog',
|
|
);
|
|
await evalPromise;
|
|
});
|
|
});
|
|
it('can handle a dialog on a non-selected page via pageId', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page1 = context.getSelectedMcpPage();
|
|
await context.newPage(); // page2 is now selected
|
|
|
|
const dialogPromise = new Promise<void>(resolve => {
|
|
page1.pptrPage.once('dialog', () => {
|
|
resolve();
|
|
});
|
|
});
|
|
const evalPromise = page1.pptrPage.evaluate(() => {
|
|
alert('test');
|
|
});
|
|
await dialogPromise;
|
|
|
|
// page1 is not selected, but its dialog should be accessible via page.
|
|
await handleDialog.handler(
|
|
{
|
|
params: {
|
|
action: 'accept',
|
|
},
|
|
page: page1,
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(page1.getDialog(), undefined);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully accepted the dialog',
|
|
);
|
|
await evalPromise;
|
|
});
|
|
});
|
|
it('tracks dialogs independently per page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page1 = context.getSelectedMcpPage();
|
|
await context.newPage();
|
|
const page2 = context.getSelectedMcpPage();
|
|
|
|
// Trigger dialog on page1.
|
|
const dialog1Promise = new Promise<void>(resolve => {
|
|
page1.pptrPage.once('dialog', () => {
|
|
resolve();
|
|
});
|
|
});
|
|
const eval1Promise = page1.pptrPage.evaluate(() => {
|
|
alert('dialog1');
|
|
});
|
|
await dialog1Promise;
|
|
|
|
// Trigger dialog on page2.
|
|
const dialog2Promise = new Promise<void>(resolve => {
|
|
page2.pptrPage.once('dialog', () => {
|
|
resolve();
|
|
});
|
|
});
|
|
const eval2Promise = page2.pptrPage.evaluate(() => {
|
|
alert('dialog2');
|
|
});
|
|
await dialog2Promise;
|
|
|
|
// Both dialogs should be tracked.
|
|
assert.ok(page1.getDialog());
|
|
assert.ok(page2.getDialog());
|
|
|
|
// Handle page1's dialog; page2's should remain.
|
|
await handleDialog.handler(
|
|
{params: {action: 'accept'}, page: page1},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(page1.getDialog(), undefined);
|
|
assert.ok(page2.getDialog());
|
|
|
|
// Handle page2's dialog.
|
|
await handleDialog.handler(
|
|
{params: {action: 'dismiss'}, page: page2},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(page2.getDialog(), undefined);
|
|
|
|
await Promise.all([eval1Promise, eval2Promise]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get_tab_id', () => {
|
|
it('returns the tab id', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
// @ts-expect-error _tabId is internal.
|
|
assert.ok(typeof page._tabId === 'string');
|
|
// @ts-expect-error _tabId is internal.
|
|
page._tabId = 'test-tab-id';
|
|
await getTabId.handler(
|
|
{params: {pageId: 1}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
const result = await response.handle('get_tab_id', context);
|
|
// @ts-expect-error _tabId is internal.
|
|
assert.strictEqual(result.structuredContent.tabId, 'test-tab-id');
|
|
assert.deepStrictEqual(response.responseLines, []);
|
|
});
|
|
});
|
|
});
|
|
});
|