827 lines
24 KiB
TypeScript
827 lines
24 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import fs from 'node:fs/promises';
|
|
import fsPromises from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
|
|
import type {TargetUniverse} from './devtools/DevtoolsUtils.js';
|
|
import {overrideDevToolsGlobals} from './devtools/DevtoolsUtils.js';
|
|
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
|
|
import type {
|
|
HeapSnapshotAggregateData,
|
|
HeapSnapshotClassDiff,
|
|
HeapSnapshotDetailedClassDiff,
|
|
DuplicateStringGroup,
|
|
} from './HeapSnapshotManager.js';
|
|
import {McpPage} from './McpPage.js';
|
|
import {type UncaughtError} from './PageCollector.js';
|
|
import {ServiceWorkerConsoleCollector} from './ServiceWorkerCollector.js';
|
|
import {
|
|
Locator,
|
|
type Browser,
|
|
type BrowserContext,
|
|
type ConsoleMessage,
|
|
type Page,
|
|
type ScreenRecorder,
|
|
type Target,
|
|
type Extension,
|
|
type Root,
|
|
type DevTools,
|
|
} from './third_party/index.js';
|
|
import {listPages} from './tools/pages.js';
|
|
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
|
|
import type {Context, SupportedExtensions} from './tools/ToolDefinition.js';
|
|
import type {TraceResult} from './trace-processing/parse.js';
|
|
import type {Logger} from './types.js';
|
|
import type {ExtensionServiceWorker} from './types.js';
|
|
import {getTempFilePath, resolveCanonicalPath} from './utils/files.js';
|
|
interface McpContextOptions {
|
|
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
|
|
experimentalDevToolsDebugging: boolean;
|
|
// Whether all page-like targets are exposed as pages.
|
|
experimentalIncludeAllPages?: boolean;
|
|
// Whether CrUX data should be fetched.
|
|
performanceCrux: boolean;
|
|
// The allow list of URL patterns to allow loading resources.
|
|
allowList?: string[];
|
|
// The block list of URL patterns to block loading resources.
|
|
blocklist?: string[];
|
|
// Whether to skip path validation when the client did not negotiate the roots
|
|
// capability. When false (default), file-writing tools are restricted to the
|
|
// OS temp directory. When true, the previous permissive behavior is restored.
|
|
allowUnrestrictedPaths?: boolean;
|
|
}
|
|
|
|
export class McpContext implements Context {
|
|
browser: Browser;
|
|
logger: Logger;
|
|
|
|
// Maps LLM-provided isolatedContext name → Puppeteer BrowserContext.
|
|
#isolatedContexts = new Map<string, BrowserContext>();
|
|
// Auto-generated name counter for when no name is provided.
|
|
#nextIsolatedContextId = 1;
|
|
|
|
#extensionServiceWorkers: ExtensionServiceWorker[] = [];
|
|
|
|
#mcpPages = new Map<Page, McpPage>();
|
|
#selectedPage?: McpPage;
|
|
#selectedPageFallback?: {wasClosed: boolean};
|
|
|
|
#serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector;
|
|
|
|
#isRunningTrace = false;
|
|
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
|
|
null;
|
|
|
|
#nextPageId = 1;
|
|
#extensionPages = new WeakMap<Target, Page>();
|
|
|
|
#extensionServiceWorkerMap = new WeakMap<Target, string>();
|
|
#nextExtensionServiceWorkerId = 1;
|
|
|
|
#traceResults: TraceResult[] = [];
|
|
|
|
#locatorClass: typeof Locator;
|
|
#options: McpContextOptions;
|
|
#heapSnapshotManager = new HeapSnapshotManager();
|
|
#roots: Root[] | undefined = undefined;
|
|
#allowUnrestrictedPaths: boolean;
|
|
|
|
private constructor(
|
|
browser: Browser,
|
|
logger: Logger,
|
|
options: McpContextOptions,
|
|
locatorClass: typeof Locator,
|
|
) {
|
|
overrideDevToolsGlobals({
|
|
loadResource: (url: string) => {
|
|
return this.loadResource(url);
|
|
},
|
|
});
|
|
|
|
this.browser = browser;
|
|
this.logger = logger;
|
|
this.#locatorClass = locatorClass;
|
|
this.#options = options;
|
|
this.#allowUnrestrictedPaths = options.allowUnrestrictedPaths ?? false;
|
|
|
|
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
|
|
this.browser,
|
|
);
|
|
}
|
|
|
|
async #init() {
|
|
await this.createPagesSnapshot();
|
|
const workers = await this.createExtensionServiceWorkersSnapshot();
|
|
|
|
await this.#serviceWorkerConsoleCollector.init(workers);
|
|
this.browser.on('targetcreated', this.#onTargetCreated);
|
|
this.browser.on('targetdestroyed', this.#onTargetDestroyed);
|
|
}
|
|
|
|
dispose() {
|
|
this.browser.off('targetcreated', this.#onTargetCreated);
|
|
this.browser.off('targetdestroyed', this.#onTargetDestroyed);
|
|
|
|
this.#serviceWorkerConsoleCollector.dispose();
|
|
for (const mcpPage of this.#mcpPages.values()) {
|
|
mcpPage.dispose();
|
|
}
|
|
this.#mcpPages.clear();
|
|
// Isolated contexts are intentionally not closed here.
|
|
// Either the entire browser will be closed or we disconnect
|
|
// without destroying browser state.
|
|
this.#isolatedContexts.clear();
|
|
}
|
|
|
|
#onTargetCreated = async (target: Target) => {
|
|
try {
|
|
const page = await target.page();
|
|
if (!page) {
|
|
return;
|
|
}
|
|
this.#createMcpPage(page);
|
|
} catch (err) {
|
|
this.logger?.('Error handling targetcreated', err);
|
|
}
|
|
};
|
|
|
|
#onTargetDestroyed = (target: Target) => {
|
|
try {
|
|
let foundPage: Page | undefined;
|
|
for (const page of this.#mcpPages.keys()) {
|
|
if (page.target() === target) {
|
|
foundPage = page;
|
|
break;
|
|
}
|
|
}
|
|
if (!foundPage) {
|
|
return;
|
|
}
|
|
const mcpPage = this.#mcpPages.get(foundPage);
|
|
if (mcpPage) {
|
|
mcpPage.dispose();
|
|
this.#mcpPages.delete(foundPage);
|
|
}
|
|
} catch (err) {
|
|
this.logger?.('Error handling targetdestroyed', err);
|
|
}
|
|
};
|
|
|
|
static async from(
|
|
browser: Browser,
|
|
logger: Logger,
|
|
opts: McpContextOptions,
|
|
/* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
|
|
locatorClass: typeof Locator = Locator,
|
|
) {
|
|
const context = new McpContext(browser, logger, opts, locatorClass);
|
|
await context.#init();
|
|
return context;
|
|
}
|
|
|
|
roots(): Root[] {
|
|
return [
|
|
...(this.#roots ?? []),
|
|
{
|
|
uri: pathToFileURL(os.tmpdir()).href,
|
|
name: 'temp',
|
|
},
|
|
];
|
|
}
|
|
|
|
setRoots(roots: Root[] | undefined): void {
|
|
this.#roots = roots;
|
|
}
|
|
|
|
async validatePath(filePath?: string): Promise<void> {
|
|
if (filePath === undefined) {
|
|
return;
|
|
}
|
|
// If the client never negotiated roots and the operator has explicitly
|
|
// opted into unrestricted access via --allow-unrestricted-paths, restore
|
|
// the previous permissive behavior and skip validation.
|
|
if (this.#roots === undefined && this.#allowUnrestrictedPaths) {
|
|
return;
|
|
}
|
|
// roots() always returns at least the temp directory, even if the
|
|
// connecting client never negotiated the optional `roots` capability.
|
|
// Path validation must not be skipped just because no workspace roots
|
|
// were configured.
|
|
const roots = this.roots();
|
|
|
|
let canonicalPath: string;
|
|
|
|
try {
|
|
canonicalPath = await resolveCanonicalPath(filePath);
|
|
} catch (err) {
|
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
console.error(
|
|
`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`,
|
|
);
|
|
throw new Error(
|
|
`Access denied: Cannot resolve base path for ${filePath}.`,
|
|
);
|
|
}
|
|
|
|
let allowed = false;
|
|
const resolvedRoots = await Promise.allSettled(
|
|
roots.map(async root => {
|
|
const rootPathUri = root.uri;
|
|
const rootPath = path.resolve(fileURLToPath(rootPathUri));
|
|
return await fsPromises.realpath(rootPath);
|
|
}),
|
|
);
|
|
|
|
for (let i = 0; i < roots.length; i++) {
|
|
const root = roots[i];
|
|
const result = resolvedRoots[i];
|
|
|
|
if (result.status === 'fulfilled') {
|
|
const canonicalRoot = result.value;
|
|
if (
|
|
canonicalPath === canonicalRoot ||
|
|
canonicalPath.startsWith(canonicalRoot + path.sep)
|
|
) {
|
|
allowed = true;
|
|
break;
|
|
}
|
|
} else {
|
|
const rootErr = result.reason;
|
|
const errMsg =
|
|
rootErr instanceof Error ? rootErr.message : String(rootErr);
|
|
console.warn(
|
|
`[MCP Context] Could not resolve configured root ${root.uri}: ${errMsg}`,
|
|
);
|
|
// Skip this root if it cannot be resolved.
|
|
}
|
|
}
|
|
|
|
if (!allowed) {
|
|
throw new Error(
|
|
`Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async ensureExtension<Extension extends `.${string}`>(
|
|
filePath: string,
|
|
extension: Extension,
|
|
): Promise<`${string}${Extension}`> {
|
|
const resolvedPath = path.resolve(filePath);
|
|
const currentExtension = path.extname(resolvedPath);
|
|
const outputPath: `${string}${Extension}` = `${resolvedPath.slice(
|
|
0,
|
|
resolvedPath.length - currentExtension.length,
|
|
)}${extension}`;
|
|
await this.validatePath(outputPath);
|
|
return outputPath;
|
|
}
|
|
|
|
getDevToolsUniverse(page: McpPage): TargetUniverse | null {
|
|
return page.devtoolsUniverse ?? null;
|
|
}
|
|
|
|
async newPage(
|
|
background?: boolean,
|
|
isolatedContextName?: string,
|
|
): Promise<McpPage> {
|
|
let page: Page;
|
|
if (isolatedContextName !== undefined) {
|
|
let ctx = this.#isolatedContexts.get(isolatedContextName);
|
|
if (!ctx) {
|
|
ctx = await this.browser.createBrowserContext();
|
|
this.#isolatedContexts.set(isolatedContextName, ctx);
|
|
}
|
|
page = await ctx.newPage();
|
|
} else {
|
|
page = await this.browser.newPage({background});
|
|
}
|
|
const mcpPage = this.#createMcpPage(page);
|
|
await this.createPagesSnapshot();
|
|
this.selectPage(mcpPage);
|
|
return mcpPage;
|
|
}
|
|
async closePage(pageId: number): Promise<void> {
|
|
if (this.getPages().length === 1) {
|
|
throw new Error(CLOSE_PAGE_ERROR);
|
|
}
|
|
const page = this.getPageById(pageId);
|
|
if (page) {
|
|
page.dispose();
|
|
this.#mcpPages.delete(page.pptrPage);
|
|
}
|
|
await page.pptrPage.close({runBeforeUnload: false});
|
|
}
|
|
|
|
get #hasNetworkBlockOrAllowlist(): boolean {
|
|
return !!(this.#options.allowList || this.#options.blocklist);
|
|
}
|
|
|
|
setIsRunningPerformanceTrace(x: boolean): void {
|
|
this.#isRunningTrace = x;
|
|
}
|
|
|
|
isRunningPerformanceTrace(): boolean {
|
|
return this.#isRunningTrace;
|
|
}
|
|
|
|
getScreenRecorder(): {recorder: ScreenRecorder; filePath: string} | null {
|
|
return this.#screenRecorderData;
|
|
}
|
|
|
|
setScreenRecorder(
|
|
data: {recorder: ScreenRecorder; filePath: string} | null,
|
|
): void {
|
|
this.#screenRecorderData = data;
|
|
}
|
|
|
|
isCruxEnabled(): boolean {
|
|
return this.#options.performanceCrux;
|
|
}
|
|
|
|
getSelectedMcpPage(): McpPage {
|
|
const page = this.#selectedPage;
|
|
if (!page) {
|
|
throw new Error('No page selected');
|
|
}
|
|
if (page.pptrPage.isClosed()) {
|
|
throw new Error(
|
|
`The selected page has been closed. Call ${listPages().name} to see open pages.`,
|
|
);
|
|
}
|
|
return page;
|
|
}
|
|
|
|
getPageById(pageId: number): McpPage {
|
|
// Resolve only among listed pages (`getPages()`), so an id that
|
|
// `list_pages` never showed cannot be targeted (e.g. a `devtools://`
|
|
// frontend, which the listing excludes unless `experimentalDevToolsDebugging`
|
|
// is set).
|
|
const page = this.getPages().find(mcpPage => mcpPage.id === pageId);
|
|
if (!page) {
|
|
throw new Error('No page found');
|
|
}
|
|
return page;
|
|
}
|
|
|
|
isPageSelected(page: McpPage): boolean {
|
|
return this.#selectedPage === page;
|
|
}
|
|
|
|
selectPage(newPage: McpPage): void {
|
|
this.#selectedPage = newPage;
|
|
newPage.updateTimeouts();
|
|
}
|
|
|
|
/**
|
|
* Returns details about the last page snapshot automatically replacing the
|
|
* selection because the selected page disappeared from the page list, or
|
|
* `undefined` if the snapshot left the selection intact. Recomputed on every
|
|
* createPagesSnapshot() call.
|
|
*/
|
|
getSelectedPageFallback(): {wasClosed: boolean} | undefined {
|
|
return this.#selectedPageFallback;
|
|
}
|
|
|
|
/**
|
|
* Creates a snapshot of the extension service workers.
|
|
*/
|
|
async createExtensionServiceWorkersSnapshot(): Promise<
|
|
ExtensionServiceWorker[]
|
|
> {
|
|
const allTargets = this.browser.targets();
|
|
|
|
const serviceWorkers = allTargets.filter(target => {
|
|
return (
|
|
target.type() === 'service_worker' &&
|
|
target.url().includes('chrome-extension://')
|
|
);
|
|
});
|
|
|
|
for (const serviceWorker of serviceWorkers) {
|
|
if (!this.#extensionServiceWorkerMap.has(serviceWorker)) {
|
|
this.#extensionServiceWorkerMap.set(
|
|
serviceWorker,
|
|
'sw-' + this.#nextExtensionServiceWorkerId++,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.#extensionServiceWorkers = serviceWorkers.map(serviceWorker => {
|
|
return {
|
|
target: serviceWorker,
|
|
id: this.#extensionServiceWorkerMap.get(serviceWorker)!,
|
|
url: serviceWorker.url(),
|
|
};
|
|
});
|
|
|
|
return this.#extensionServiceWorkers;
|
|
}
|
|
|
|
getServiceWorkerConsoleData(
|
|
extensionId: string,
|
|
): Array<ConsoleMessage | UncaughtError> {
|
|
return this.#serviceWorkerConsoleCollector.getData(extensionId);
|
|
}
|
|
|
|
#createMcpPage(page: Page): McpPage {
|
|
let mcpPage = this.#mcpPages.get(page);
|
|
if (!mcpPage) {
|
|
mcpPage = new McpPage(page, this.#nextPageId++, {
|
|
locatorClass: this.#locatorClass,
|
|
hasNetworkBlockOrAllowlist: this.#hasNetworkBlockOrAllowlist,
|
|
});
|
|
this.#mcpPages.set(page, mcpPage);
|
|
void mcpPage.init();
|
|
}
|
|
return mcpPage;
|
|
}
|
|
|
|
async createPagesSnapshot(): Promise<Page[]> {
|
|
const {pages: allPages, isolatedContextNames} =
|
|
await this.#fetchBrowserPages();
|
|
|
|
for (const page of allPages) {
|
|
const mcpPage = this.#createMcpPage(page);
|
|
mcpPage.isolatedContextName = isolatedContextNames.get(page);
|
|
}
|
|
|
|
// Prune orphaned #mcpPages entries (pages that no longer exist).
|
|
const currentPages = new Set(allPages);
|
|
for (const [page, mcpPage] of this.#mcpPages) {
|
|
if (!currentPages.has(page)) {
|
|
mcpPage.dispose();
|
|
this.#mcpPages.delete(page);
|
|
}
|
|
}
|
|
|
|
const pages = this.getPages();
|
|
|
|
// Only fall back when the selected page is actually gone. Gating on
|
|
// `isClosed()` instead of `pages` membership avoids silently swapping a
|
|
// live page that is momentarily missing from the snapshot.
|
|
this.#selectedPageFallback = undefined;
|
|
if (
|
|
(!this.#selectedPage || this.#selectedPage.pptrPage.isClosed()) &&
|
|
pages[0]
|
|
) {
|
|
// Record the automatic change so the response can surface it. Skipped on
|
|
// first connect, when there was no prior selection to replace.
|
|
if (this.#selectedPage) {
|
|
this.#selectedPageFallback = {
|
|
wasClosed: this.#selectedPage.pptrPage.isClosed(),
|
|
};
|
|
}
|
|
this.selectPage(pages[0]);
|
|
}
|
|
|
|
await this.detectOpenDevToolsWindows();
|
|
|
|
return pages.map(p => p.pptrPage);
|
|
}
|
|
|
|
async #fetchBrowserPages(): Promise<{
|
|
pages: Page[];
|
|
isolatedContextNames: Map<Page, string>;
|
|
}> {
|
|
const defaultCtx = this.browser.defaultBrowserContext();
|
|
const allPages = await this.browser.pages(
|
|
this.#options.experimentalIncludeAllPages,
|
|
);
|
|
|
|
const allTargets = this.browser.targets();
|
|
const extensionTargets = allTargets.filter(target => {
|
|
return (
|
|
target.url().startsWith('chrome-extension://') &&
|
|
target.type() === 'page'
|
|
);
|
|
});
|
|
|
|
for (const target of extensionTargets) {
|
|
// Right now target.page() returns null for popup and side panel pages.
|
|
let page = await target.page();
|
|
if (!page) {
|
|
// We need to cache pages instances for targets because target.asPage()
|
|
// returns a new page instance every time.
|
|
page = this.#extensionPages.get(target) ?? null;
|
|
if (!page) {
|
|
try {
|
|
page = await target.asPage();
|
|
this.#extensionPages.set(target, page);
|
|
} catch (e) {
|
|
this.logger?.('Failed to get page for extension target', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (page && !allPages.includes(page)) {
|
|
allPages.push(page);
|
|
}
|
|
}
|
|
|
|
// Build a reverse lookup from BrowserContext instance → name.
|
|
const contextToName = new Map<BrowserContext, string>();
|
|
for (const [name, ctx] of this.#isolatedContexts) {
|
|
contextToName.set(ctx, name);
|
|
}
|
|
|
|
// Auto-discover BrowserContexts not in our mapping (e.g., externally
|
|
// created incognito contexts) and assign generated names.
|
|
const knownContexts = new Set(this.#isolatedContexts.values());
|
|
for (const ctx of this.browser.browserContexts()) {
|
|
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
|
|
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
|
|
this.#isolatedContexts.set(name, ctx);
|
|
contextToName.set(ctx, name);
|
|
}
|
|
}
|
|
|
|
// Map each page to its isolated context name (if any).
|
|
const isolatedContextNames = new Map<Page, string>();
|
|
for (const page of allPages) {
|
|
const ctx = page.browserContext();
|
|
const name = contextToName.get(ctx);
|
|
if (name) {
|
|
isolatedContextNames.set(page, name);
|
|
}
|
|
}
|
|
|
|
return {pages: allPages, isolatedContextNames};
|
|
}
|
|
|
|
async detectOpenDevToolsWindows() {
|
|
this.logger?.('Detecting open DevTools windows');
|
|
|
|
await Promise.all(
|
|
Array.from(this.#mcpPages.values()).map(async mcpPage => {
|
|
const page = mcpPage.pptrPage;
|
|
// Prior to Chrome 144.0.7559.59, the command fails,
|
|
// Some Electron apps still use older version
|
|
// Fall back to not exposing DevTools at all.
|
|
try {
|
|
if (await page.hasDevTools()) {
|
|
mcpPage.devToolsPage = await page.openDevTools();
|
|
} else {
|
|
mcpPage.devToolsPage = undefined;
|
|
}
|
|
} catch {
|
|
mcpPage.devToolsPage = undefined;
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
getExtensionServiceWorkers(): ExtensionServiceWorker[] {
|
|
return this.#extensionServiceWorkers;
|
|
}
|
|
|
|
getExtensionServiceWorkerId(
|
|
extensionServiceWorker: ExtensionServiceWorker,
|
|
): string | undefined {
|
|
return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target);
|
|
}
|
|
|
|
getPages(): McpPage[] {
|
|
return Array.from(this.#mcpPages.values()).filter(mcpPage => {
|
|
return (
|
|
this.#options.experimentalDevToolsDebugging ||
|
|
!mcpPage.pptrPage.url().startsWith('devtools://')
|
|
);
|
|
});
|
|
}
|
|
|
|
async saveTemporaryFile(
|
|
data: Uint8Array<ArrayBufferLike>,
|
|
filename: string,
|
|
): Promise<{filepath: string}> {
|
|
const filepath = await getTempFilePath(filename);
|
|
await this.validatePath(filepath);
|
|
try {
|
|
await fs.writeFile(filepath, data);
|
|
} catch (err) {
|
|
throw new Error('Could not save a file', {cause: err});
|
|
}
|
|
return {filepath};
|
|
}
|
|
|
|
async saveFile(
|
|
data: Uint8Array<ArrayBufferLike>,
|
|
clientProvidedFilePath: string,
|
|
extension: SupportedExtensions,
|
|
): Promise<{filename: string}> {
|
|
const filePath = await this.ensureExtension(
|
|
clientProvidedFilePath,
|
|
extension,
|
|
);
|
|
try {
|
|
await fs.mkdir(path.dirname(filePath), {recursive: true});
|
|
await fs.writeFile(filePath, data);
|
|
return {filename: filePath};
|
|
} catch (err) {
|
|
this.logger?.(err);
|
|
throw new Error('Could not save a file', {cause: err});
|
|
}
|
|
}
|
|
|
|
storeTraceRecording(result: TraceResult): void {
|
|
// Clear the trace results because we only consume the latest trace currently.
|
|
this.#traceResults = [];
|
|
this.#traceResults.push(result);
|
|
}
|
|
|
|
recordedTraces(): TraceResult[] {
|
|
return this.#traceResults;
|
|
}
|
|
|
|
async installExtension(extensionPath: string): Promise<string> {
|
|
const id = await this.browser.installExtension(extensionPath);
|
|
return id;
|
|
}
|
|
|
|
async uninstallExtension(id: string): Promise<void> {
|
|
await this.browser.uninstallExtension(id);
|
|
}
|
|
|
|
async triggerExtensionAction(id: string): Promise<void> {
|
|
const extensions = await this.browser.extensions();
|
|
const extension = extensions.get(id);
|
|
if (!extension) {
|
|
throw new Error(`Extension with ID ${id} not found.`);
|
|
}
|
|
const page = this.getSelectedMcpPage().pptrPage;
|
|
await extension.triggerAction(page);
|
|
}
|
|
|
|
listExtensions(): Promise<Map<string, Extension>> {
|
|
return this.browser.extensions();
|
|
}
|
|
|
|
async getExtension(id: string): Promise<Extension | undefined> {
|
|
const pptrExtensions = await this.browser.extensions();
|
|
return pptrExtensions.get(id);
|
|
}
|
|
|
|
async getHeapSnapshotAggregates(
|
|
filePath: string,
|
|
filterName?: string,
|
|
): Promise<HeapSnapshotAggregateData> {
|
|
return await this.#heapSnapshotManager.getAggregates(filePath, filterName);
|
|
}
|
|
|
|
async getHeapSnapshotDuplicateStrings(
|
|
filePath: string,
|
|
): Promise<DuplicateStringGroup[]> {
|
|
return await this.#heapSnapshotManager.getDuplicateStrings(filePath);
|
|
}
|
|
|
|
async getHeapSnapshotStats(
|
|
filePath: string,
|
|
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
|
|
return await this.#heapSnapshotManager.getStats(filePath);
|
|
}
|
|
|
|
async getHeapSnapshotStaticData(
|
|
filePath: string,
|
|
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
|
|
return await this.#heapSnapshotManager.getStaticData(filePath);
|
|
}
|
|
|
|
async getHeapSnapshotNodesById(
|
|
filePath: string,
|
|
id: number,
|
|
filterName?: string,
|
|
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
|
return await this.#heapSnapshotManager.getNodesById(
|
|
filePath,
|
|
id,
|
|
filterName,
|
|
);
|
|
}
|
|
|
|
async getHeapSnapshotRetainers(
|
|
filePath: string,
|
|
nodeId: number,
|
|
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
|
return await this.#heapSnapshotManager.getRetainers(filePath, nodeId);
|
|
}
|
|
|
|
async closeHeapSnapshot(filePath: string): Promise<boolean> {
|
|
return this.#heapSnapshotManager.dispose(filePath);
|
|
}
|
|
|
|
hasHeapSnapshots(): boolean {
|
|
return this.#heapSnapshotManager.hasSnapshots();
|
|
}
|
|
|
|
async getHeapSnapshotRetainingPaths(
|
|
filePath: string,
|
|
nodeId: number,
|
|
maxDepth?: number,
|
|
maxNodes?: number,
|
|
maxSiblings?: number,
|
|
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
|
|
return await this.#heapSnapshotManager.getRetainingPaths(
|
|
filePath,
|
|
nodeId,
|
|
maxDepth,
|
|
maxNodes,
|
|
maxSiblings,
|
|
);
|
|
}
|
|
|
|
async getHeapSnapshotDominators(
|
|
filePath: string,
|
|
nodeId: number,
|
|
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain> {
|
|
return await this.#heapSnapshotManager.getDominatorsOf(filePath, nodeId);
|
|
}
|
|
|
|
#validateUrlNotBlocked(url: URL): void {
|
|
if (!this.#options.blocklist) {
|
|
return;
|
|
}
|
|
for (const block of this.#options.blocklist) {
|
|
const pattern = new URLPattern(block);
|
|
if (pattern.test(url)) {
|
|
throw new Error(`Blocked by blocklist: ${url}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
#validateUrlAllowed(url: URL): void {
|
|
if (!this.#options.allowList) {
|
|
return;
|
|
}
|
|
for (const allow of this.#options.allowList) {
|
|
const pattern = new URLPattern(allow);
|
|
if (pattern.test(url)) {
|
|
return;
|
|
}
|
|
}
|
|
throw new Error(`Not allowed by allowlist: ${url}`);
|
|
}
|
|
|
|
async loadResource(path: string): Promise<string> {
|
|
const url = new URL(path);
|
|
|
|
this.#validateUrlNotBlocked(url);
|
|
|
|
switch (url.protocol) {
|
|
case 'https:':
|
|
case 'http:': {
|
|
this.#validateUrlAllowed(url);
|
|
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load resource: ${url}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
case 'file:': {
|
|
await this.validatePath(fileURLToPath(url));
|
|
return await fsPromises.readFile(url, 'utf-8');
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unsupported protocol for: ${url}`);
|
|
}
|
|
}
|
|
|
|
async getHeapSnapshotEdges(
|
|
filePath: string,
|
|
nodeId: number,
|
|
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
|
return await this.#heapSnapshotManager.getEdges(filePath, nodeId);
|
|
}
|
|
|
|
async getHeapSnapshotClassDiffs(
|
|
baseFilePath: string,
|
|
currentFilePath: string,
|
|
): Promise<HeapSnapshotClassDiff[]> {
|
|
return await this.#heapSnapshotManager.getClassDiffs(
|
|
baseFilePath,
|
|
currentFilePath,
|
|
);
|
|
}
|
|
|
|
async getHeapSnapshotDetailedClassDiff(
|
|
baseFilePath: string,
|
|
currentFilePath: string,
|
|
classIndex: number,
|
|
): Promise<HeapSnapshotDetailedClassDiff> {
|
|
return await this.#heapSnapshotManager.getDetailedClassDiff(
|
|
baseFilePath,
|
|
currentFilePath,
|
|
classIndex,
|
|
);
|
|
}
|
|
}
|