mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 05:02:31 +00:00
feat(context): release disabled feature state on config reload (#3049)
Adds capability-gated feature controllers to `McpContext` so runtime state is released when features transition from enabled to disabled on reload. - Adds `CapabilityGate` and `isCapabilityEnabled()` in `src/tools/ToolDefinition.ts`. - Adds `FEATURE_CONTROLLERS` in `McpContext` keyed by capability gate (`DEBUGGING` screencast, `MEMORY` + `memoryDebugging`, `PERFORMANCE`, and `DEBUGGING` + `devtoolsComments`). - Adds `McpContext.onConfigChanged(previousArgs)` to sync page source maps (`mcpPage.syncSourceMaps()`), refresh page targets when `experimentalIncludeAllPages` changes, and tear down active traces, heap snapshots, screencasts, or comment bridges when their capability is disabled (including when `--slim` is toggled). <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
This commit is contained in:
1 parent
b1e6e6850d
commit
c0140d1b4a
6 files changed
+595
-53
No files matched your search
+163
-25
@@ -40,13 +40,17 @@ import {
|
||||
type Root,
|
||||
type DevTools,
|
||||
} from './third_party/index.js';
|
||||
import type {ParsedArguments} from './config/ConfigParser.js';
|
||||
import type {ReadonlyServerConfig} from './config/ServerConfig.js';
|
||||
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
|
||||
import type {
|
||||
BrowserlessContext,
|
||||
Context,
|
||||
DevToolsData,
|
||||
SupportedExtensions,
|
||||
import {ToolCategory} from './tools/categories.js';
|
||||
import {
|
||||
CLOSE_PAGE_ERROR,
|
||||
isCapabilityEnabled,
|
||||
type BrowserlessContext,
|
||||
type CapabilityGate,
|
||||
type Context,
|
||||
type DevToolsData,
|
||||
type SupportedExtensions,
|
||||
} from './tools/ToolDefinition.js';
|
||||
import type {TraceResult} from './processors/PerformanceTrace.js';
|
||||
import type {Logger} from './types.js';
|
||||
@@ -66,6 +70,30 @@ export interface McpContextOptions {
|
||||
getBrowser: () => Promise<Browser>;
|
||||
}
|
||||
|
||||
interface FeatureController {
|
||||
gate: CapabilityGate;
|
||||
onDisable(context: McpContext): Promise<void> | void;
|
||||
}
|
||||
|
||||
const FEATURE_CONTROLLERS: readonly FeatureController[] = [
|
||||
{
|
||||
gate: {category: ToolCategory.DEBUGGING},
|
||||
onDisable: context => context.releaseScreencasts(),
|
||||
},
|
||||
{
|
||||
gate: {category: ToolCategory.MEMORY, conditions: ['memoryDebugging']},
|
||||
onDisable: context => context.releaseHeapSnapshots(),
|
||||
},
|
||||
{
|
||||
gate: {category: ToolCategory.PERFORMANCE},
|
||||
onDisable: context => context.releasePerformanceTrace(),
|
||||
},
|
||||
{
|
||||
gate: {category: ToolCategory.DEBUGGING, conditions: ['devtoolsComments']},
|
||||
onDisable: context => context.releaseDevToolsComments(),
|
||||
},
|
||||
];
|
||||
|
||||
// Page ids are handed out from a process-wide counter so they stay unique
|
||||
// across all contexts, in particular across browser reconnects. An id issued
|
||||
// before a reconnect then fails to resolve instead of hitting an unrelated
|
||||
@@ -99,8 +127,6 @@ export class McpContext implements Context, BrowserlessContext {
|
||||
|
||||
#serviceWorkerConsoleCollector?: ServiceWorkerConsoleCollector;
|
||||
|
||||
#isRunningTrace = false;
|
||||
|
||||
#reconnectNotice = false;
|
||||
|
||||
#traceResults: TraceResult[] = [];
|
||||
@@ -148,6 +174,100 @@ export class McpContext implements Context, BrowserlessContext {
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes browser/page state and releases state of capabilities that
|
||||
* transitioned from enabled to disabled after a config reload.
|
||||
*/
|
||||
async onConfigChanged(previousArgs: ParsedArguments): Promise<void> {
|
||||
const nextArgs = this.#config.resolved;
|
||||
if (nextArgs.sourceMaps !== previousArgs.sourceMaps) {
|
||||
for (const mcpPage of this.#allMcpPages()) {
|
||||
mcpPage.syncSourceMaps();
|
||||
}
|
||||
}
|
||||
if (
|
||||
nextArgs.experimentalIncludeAllPages !==
|
||||
previousArgs.experimentalIncludeAllPages
|
||||
) {
|
||||
await this.createPagesSnapshot();
|
||||
}
|
||||
for (const controller of FEATURE_CONTROLLERS) {
|
||||
if (
|
||||
isCapabilityEnabled(controller.gate, previousArgs) &&
|
||||
!isCapabilityEnabled(controller.gate, nextArgs)
|
||||
) {
|
||||
await controller.onDisable(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops and saves active screencast recordings across all pages when
|
||||
* screencast tools are disabled.
|
||||
*/
|
||||
async releaseScreencasts(): Promise<void> {
|
||||
for (const mcpPage of this.#allMcpPages()) {
|
||||
const screenRecorderData = mcpPage.getScreenRecorder();
|
||||
if (!screenRecorderData) {
|
||||
continue;
|
||||
}
|
||||
const {recorder, filePath} = screenRecorderData;
|
||||
mcpPage.setScreenRecorder(null);
|
||||
try {
|
||||
await recorder.stop();
|
||||
this.logger?.(
|
||||
`The screencast recording was stopped and saved to ${filePath} because screencast tools were disabled.`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger?.('Failed to stop the screencast recording', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all loaded heap snapshots when memory tools are disabled.
|
||||
*/
|
||||
releaseHeapSnapshots(): void {
|
||||
if (this.#heapSnapshotManager.hasSnapshots()) {
|
||||
this.#heapSnapshotManager.dispose();
|
||||
this.logger?.(
|
||||
'Loaded heap snapshots were closed because memory tools were disabled.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards recorded performance traces and stops any running trace when
|
||||
* performance tools are disabled.
|
||||
*/
|
||||
async releasePerformanceTrace(): Promise<void> {
|
||||
this.#traceResults = [];
|
||||
for (const mcpPage of this.#allMcpPages()) {
|
||||
if (!mcpPage.isRunningPerformanceTrace()) {
|
||||
continue;
|
||||
}
|
||||
mcpPage.setIsRunningPerformanceTrace(false);
|
||||
try {
|
||||
await mcpPage.pptrPage.tracing.stop();
|
||||
} catch (err) {
|
||||
this.logger?.('Failed to stop the performance trace', err);
|
||||
}
|
||||
this.logger?.(
|
||||
'The running performance trace was discarded because performance tools were disabled.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes DevTools comment bridges across all pages when DevTools comment
|
||||
* tools are disabled.
|
||||
*/
|
||||
releaseDevToolsComments(): void {
|
||||
for (const mcpPage of this.#allMcpPages()) {
|
||||
mcpPage.disposeCommentBridge();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the browser, establishing the connection if needed. If the browser
|
||||
* changed since the last call (e.g. after a reconnect), all
|
||||
@@ -221,7 +341,6 @@ export class McpContext implements Context, BrowserlessContext {
|
||||
this.#selectedPage = undefined;
|
||||
this.#selectedPageFallback = undefined;
|
||||
this.#selectedPageFallbackRead = false;
|
||||
this.#isRunningTrace = false;
|
||||
this.#traceResults = [];
|
||||
this.#serviceWorkerConsoleCollector = undefined;
|
||||
this.#browser = undefined;
|
||||
@@ -452,12 +571,13 @@ export class McpContext implements Context, BrowserlessContext {
|
||||
return this.browser.getPWAState(options);
|
||||
}
|
||||
|
||||
setIsRunningPerformanceTrace(x: boolean): void {
|
||||
this.#isRunningTrace = x;
|
||||
}
|
||||
|
||||
isRunningPerformanceTrace(): boolean {
|
||||
return this.#isRunningTrace;
|
||||
for (const mcpPage of this.#allMcpPages()) {
|
||||
if (mcpPage.isRunningPerformanceTrace()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isCruxEnabled(): boolean {
|
||||
@@ -668,6 +788,9 @@ export class McpContext implements Context, BrowserlessContext {
|
||||
}
|
||||
|
||||
async createPagesSnapshot(): Promise<McpPage[]> {
|
||||
if (!this.#browser) {
|
||||
return [];
|
||||
}
|
||||
const allTargets = this.#fetchPageTargets();
|
||||
|
||||
for (const target of allTargets) {
|
||||
@@ -687,22 +810,37 @@ export class McpContext implements Context, BrowserlessContext {
|
||||
|
||||
const pages = Array.from(this.#mcpPages.values());
|
||||
|
||||
// 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.
|
||||
// Only fall back when the selected page is actually gone or no longer
|
||||
// counts as a page, e.g. a background page after
|
||||
// experimentalIncludeAllPages was turned off. Gating on `isClosed()`
|
||||
// instead of `pages` membership avoids silently swapping a live page that
|
||||
// is momentarily missing from the snapshot.
|
||||
if (this.#selectedPageFallbackRead) {
|
||||
this.#selectedPageFallback = undefined;
|
||||
this.#selectedPageFallbackRead = false;
|
||||
}
|
||||
if ((!this.#selectedPage || this.#selectedPage.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.isClosed(),
|
||||
};
|
||||
if (
|
||||
!this.#selectedPage ||
|
||||
this.#selectedPage.isClosed() ||
|
||||
!this.#isPageTarget(this.#selectedPage.target)
|
||||
) {
|
||||
if (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.isClosed(),
|
||||
};
|
||||
}
|
||||
this.#selectPage(pages[0]);
|
||||
} else if (this.#selectedPage) {
|
||||
const wasClosed = Boolean(this.#selectedPage.isClosed());
|
||||
if (!wasClosed) {
|
||||
this.#selectedPage.dispose();
|
||||
}
|
||||
this.#selectedPage = undefined;
|
||||
this.#selectedPageFallback = {wasClosed};
|
||||
}
|
||||
this.#selectPage(pages[0]);
|
||||
}
|
||||
|
||||
if (this.#selectedPage && !this.#selectedPage.isClosed()) {
|
||||
|
||||
@@ -175,6 +175,9 @@ export class McpPage implements ContextPage {
|
||||
// Screencast
|
||||
#screenRecorder: ScreenRecorderData | null = null;
|
||||
|
||||
// Performance trace
|
||||
#isRunningPerformanceTrace = false;
|
||||
|
||||
#networkCollector?: NetworkCollector;
|
||||
#consoleCollector?: ConsoleCollector;
|
||||
|
||||
@@ -399,6 +402,14 @@ export class McpPage implements ContextPage {
|
||||
this.#screenRecorder = data;
|
||||
}
|
||||
|
||||
isRunningPerformanceTrace(): boolean {
|
||||
return this.#isRunningPerformanceTrace;
|
||||
}
|
||||
|
||||
setIsRunningPerformanceTrace(x: boolean): void {
|
||||
this.#isRunningPerformanceTrace = x;
|
||||
}
|
||||
|
||||
throwIfDialogOpen(): void {
|
||||
const dialog = this.getDialog();
|
||||
if (dialog) {
|
||||
@@ -686,6 +697,7 @@ export class McpPage implements ContextPage {
|
||||
|
||||
dispose(): void {
|
||||
this.#disposed = true;
|
||||
this.#isRunningPerformanceTrace = false;
|
||||
this.disposeCommentBridge();
|
||||
if (this.#screenRecorder) {
|
||||
const {recorder} = this.#screenRecorder;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {categoryToFlagName} from '../config/category-options.js';
|
||||
import type {ParsedArguments} from '../config/ConfigParser.js';
|
||||
import type {
|
||||
HeapSnapshotAggregateData,
|
||||
@@ -91,6 +92,11 @@ export type MergeSchema<Schema extends zod.ZodRawShape> = {
|
||||
: zod.ZodOptional<ExtractSchemaField<Schema, K>>;
|
||||
};
|
||||
|
||||
export interface CapabilityGate {
|
||||
category?: ToolCategory;
|
||||
conditions?: Array<keyof ParsedArguments>;
|
||||
}
|
||||
|
||||
export interface BaseToolDefinition<
|
||||
Schema extends zod.ZodRawShape = zod.ZodRawShape,
|
||||
> {
|
||||
@@ -336,7 +342,6 @@ export type Context = Readonly<{
|
||||
extension: Extension,
|
||||
): Promise<`${string}${Extension}`>;
|
||||
isRunningPerformanceTrace(): boolean;
|
||||
setIsRunningPerformanceTrace(x: boolean): void;
|
||||
isCruxEnabled(): boolean;
|
||||
recordedTraces(): TraceResult[];
|
||||
storeTraceRecording(result: TraceResult): void;
|
||||
@@ -472,6 +477,7 @@ export type ContextPage = Readonly<{
|
||||
throwIfDialogOpen(): void;
|
||||
getScreenRecorder(): ScreenRecorderData | null;
|
||||
setScreenRecorder(data: ScreenRecorderData | null): void;
|
||||
setIsRunningPerformanceTrace(x: boolean): void;
|
||||
waitForEventsAfterAction(
|
||||
action: (signal: AbortSignal) => Promise<unknown>,
|
||||
options?: {
|
||||
@@ -530,11 +536,6 @@ export type DefinedPageTool<Schema extends zod.ZodRawShape = zod.ZodRawShape> =
|
||||
Omit<PageToolDefinition<Schema>, 'schema'> & {
|
||||
schema: Schema & Partial<typeof pageIdSchema>;
|
||||
pageScoped: true;
|
||||
handler(
|
||||
request: Request<Schema> & {page: ContextPage},
|
||||
response: Response,
|
||||
context: Context,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
export function definePageTool<Schema extends zod.ZodRawShape>(
|
||||
@@ -750,8 +751,7 @@ export function isSlimTool(
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim mode replaces the regular tools with the slim tools. `--slim` requires
|
||||
* a restart, so the mode never changes while the server is running.
|
||||
* Slim mode replaces the regular tools with the slim tools.
|
||||
*/
|
||||
export function isAvailableInMode(
|
||||
tool: Pick<BaseToolDefinition, 'annotations'>,
|
||||
@@ -759,3 +759,24 @@ export function isAvailableInMode(
|
||||
): boolean {
|
||||
return isSlimTool(tool) === Boolean(serverArgs.slim);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a capability gate is enabled under the given arguments.
|
||||
*/
|
||||
export function isCapabilityEnabled(
|
||||
gate: CapabilityGate,
|
||||
serverArgs: ParsedArguments,
|
||||
): boolean {
|
||||
if (Boolean(gate.conditions?.includes('slim')) !== Boolean(serverArgs.slim)) {
|
||||
return false;
|
||||
}
|
||||
if (gate.category && !serverArgs[categoryToFlagName(gate.category)]) {
|
||||
return false;
|
||||
}
|
||||
for (const condition of gate.conditions ?? []) {
|
||||
if (!serverArgs[condition]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -64,9 +64,9 @@ export const startTrace = definePageTool(() => ({
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
|
||||
const page = request.page;
|
||||
page.setIsRunningPerformanceTrace(true);
|
||||
|
||||
const pageUrlForTracing = page.pptrPage.url();
|
||||
|
||||
try {
|
||||
@@ -134,7 +134,7 @@ export const startTrace = definePageTool(() => ({
|
||||
} catch {
|
||||
// Tracing may not have started yet; ignore.
|
||||
}
|
||||
context.setIsRunningPerformanceTrace(false);
|
||||
page.setIsRunningPerformanceTrace(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -259,7 +259,7 @@ async function stopTracingAndAppendOutput(
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
context.setIsRunningPerformanceTrace(false);
|
||||
page.setIsRunningPerformanceTrace(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+374
-7
@@ -24,13 +24,19 @@ import {TextSnapshot} from '../src/TextSnapshot.js';
|
||||
import {
|
||||
type Browser,
|
||||
type HTTPResponse,
|
||||
ScreenRecorder,
|
||||
type Target,
|
||||
TargetType,
|
||||
} from '../src/third_party/index.js';
|
||||
import type {TraceResult} from '../src/processors/PerformanceTrace.js';
|
||||
import {
|
||||
parseRawTraceBuffer,
|
||||
type TraceResult,
|
||||
traceResultIsSuccess,
|
||||
} from '../src/processors/PerformanceTrace.js';
|
||||
import {resolveCanonicalPath} from '../src/utils/files.js';
|
||||
import {stableIdSymbol} from '../src/utils/id.js';
|
||||
|
||||
import type {ServerConfig} from '../src/config/ServerConfig.js';
|
||||
import {
|
||||
createMockMcpWorker,
|
||||
createMockPuppeteerBrowser,
|
||||
@@ -39,6 +45,7 @@ import {
|
||||
createMockServerConfig,
|
||||
} from './mocks.js';
|
||||
import {serverHooks, TestServer} from './server.js';
|
||||
import {loadTraceAsBuffer} from './trace-processing/fixtures/load.js';
|
||||
import {
|
||||
assertNoServiceWorkerReported,
|
||||
createConnectedMcpContext,
|
||||
@@ -72,17 +79,19 @@ describe('McpContext', () => {
|
||||
|
||||
async function createMockContext(options?: {
|
||||
browser?: sinon.SinonStubbedInstance<Browser>;
|
||||
config?: ServerConfig;
|
||||
heapSnapshotManager?: HeapSnapshotManager;
|
||||
targets?: Target[];
|
||||
}) {
|
||||
const browser = options?.browser ?? createMockPuppeteerBrowser();
|
||||
const {target: defaultTarget} = createPageTarget();
|
||||
const defaultTarget = createMockPuppeteerTarget();
|
||||
browser.targets.returns(options?.targets ?? [defaultTarget]);
|
||||
browser.browserContexts.returns([]);
|
||||
return await createConnectedMcpContext(
|
||||
browser,
|
||||
{
|
||||
config: createMockServerConfig(),
|
||||
heapSnapshotManager: new HeapSnapshotManager(),
|
||||
config: options?.config ?? createMockServerConfig(),
|
||||
heapSnapshotManager:
|
||||
options?.heapSnapshotManager ?? new HeapSnapshotManager(),
|
||||
},
|
||||
Locator,
|
||||
);
|
||||
@@ -172,7 +181,6 @@ describe('McpContext', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('list pages', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const page = context.getSelectedMcpPage();
|
||||
@@ -548,6 +556,131 @@ describe('McpContext', () => {
|
||||
sinon.assert.calledOnceWithExactly(disposeSpy);
|
||||
});
|
||||
|
||||
async function updateConfig(
|
||||
config: ServerConfig,
|
||||
context: McpContext,
|
||||
next: Partial<ServerConfig['resolved']>,
|
||||
): Promise<void> {
|
||||
const previousArgs = config.resolved;
|
||||
config.commit(createMockServerConfig({...previousArgs, ...next}));
|
||||
await context.onConfigChanged(previousArgs);
|
||||
}
|
||||
|
||||
it('falls back when the selected page is no longer a valid page target', async () => {
|
||||
const regularTarget = createMockPuppeteerTarget();
|
||||
const bgTarget = createMockPuppeteerTarget({
|
||||
type: TargetType.BACKGROUND_PAGE,
|
||||
});
|
||||
const config = createMockServerConfig({
|
||||
experimentalIncludeAllPages: true,
|
||||
});
|
||||
const context = await createMockContext({
|
||||
config,
|
||||
targets: [regularTarget, bgTarget],
|
||||
});
|
||||
const [regularPage, bgPage] = context.getPages();
|
||||
assert.ok(regularPage);
|
||||
assert.ok(bgPage);
|
||||
context.selectPage(bgPage);
|
||||
const disposeSpy = sinon.spy(bgPage, 'dispose');
|
||||
|
||||
await updateConfig(config, context, {
|
||||
experimentalIncludeAllPages: false,
|
||||
});
|
||||
|
||||
assert.ok(context.isPageSelected(regularPage));
|
||||
sinon.assert.calledOnceWithExactly(disposeSpy);
|
||||
assert.deepStrictEqual(context.getSelectedPageFallback(), {
|
||||
wasClosed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the selection when the selected page is no longer a valid page target and no other pages exist', async () => {
|
||||
const bgTarget = createMockPuppeteerTarget({
|
||||
type: TargetType.BACKGROUND_PAGE,
|
||||
});
|
||||
const config = createMockServerConfig({
|
||||
experimentalIncludeAllPages: true,
|
||||
});
|
||||
const context = await createMockContext({
|
||||
config,
|
||||
targets: [bgTarget],
|
||||
});
|
||||
const bgPage = context.getSelectedMcpPage();
|
||||
const disposeSpy = sinon.spy(bgPage, 'dispose');
|
||||
|
||||
await updateConfig(config, context, {
|
||||
experimentalIncludeAllPages: false,
|
||||
});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(disposeSpy);
|
||||
assert.throws(() => context.getSelectedMcpPage(), /No page selected/);
|
||||
assert.deepStrictEqual(context.getSelectedPageFallback(), {
|
||||
wasClosed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an unread fallback when the selection is cleared and a later snapshot selects a new page', async () => {
|
||||
const browser = createMockPuppeteerBrowser();
|
||||
const bgTarget = createMockPuppeteerTarget({
|
||||
type: TargetType.BACKGROUND_PAGE,
|
||||
});
|
||||
const config = createMockServerConfig({
|
||||
experimentalIncludeAllPages: true,
|
||||
});
|
||||
const context = await createMockContext({
|
||||
browser,
|
||||
config,
|
||||
targets: [bgTarget],
|
||||
});
|
||||
|
||||
await updateConfig(config, context, {
|
||||
experimentalIncludeAllPages: false,
|
||||
});
|
||||
assert.throws(() => context.getSelectedMcpPage(), /No page selected/);
|
||||
|
||||
const regularTarget = createMockPuppeteerTarget();
|
||||
browser.targets.returns([regularTarget, bgTarget]);
|
||||
await context.createPagesSnapshot();
|
||||
|
||||
const [regularPage] = context.getPages();
|
||||
assert.ok(regularPage);
|
||||
assert.ok(context.isPageSelected(regularPage));
|
||||
assert.deepStrictEqual(context.getSelectedPageFallback(), {
|
||||
wasClosed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not operate on a closed and already-disposed selected page in onConfigChanged or release methods', async () => {
|
||||
const browser = createMockPuppeteerBrowser();
|
||||
const pptrPage = createMockPuppeteerPage();
|
||||
const target = createMockPuppeteerTarget({page: pptrPage});
|
||||
const config = createMockServerConfig();
|
||||
const context = await createMockContext({
|
||||
browser,
|
||||
config,
|
||||
targets: [target],
|
||||
});
|
||||
const page = context.getSelectedMcpPage();
|
||||
pptrPage.isClosed.returns(true);
|
||||
browser.targets.returns([]);
|
||||
|
||||
await context.createPagesSnapshot();
|
||||
|
||||
const syncSourceMapsSpy = sinon.spy(page, 'syncSourceMaps');
|
||||
const disposeCommentBridgeSpy = sinon.spy(page, 'disposeCommentBridge');
|
||||
const disposeSpy = sinon.spy(page, 'dispose');
|
||||
|
||||
await updateConfig(config, context, {sourceMaps: false});
|
||||
await context.releaseScreencasts();
|
||||
context.releaseDevToolsComments();
|
||||
context.dispose();
|
||||
|
||||
sinon.assert.notCalled(syncSourceMapsSpy);
|
||||
sinon.assert.notCalled(disposeCommentBridgeSpy);
|
||||
sinon.assert.notCalled(disposeSpy);
|
||||
});
|
||||
|
||||
it('does not report a fallback for a regular selection', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
await context.newPage();
|
||||
@@ -683,6 +816,7 @@ describe('McpContext', () => {
|
||||
|
||||
it('resets the browser state and reports a reconnect notice once when the browser changes', async () => {
|
||||
const firstBrowser = createBrowser();
|
||||
firstBrowser.targets.returns([createMockPuppeteerTarget()]);
|
||||
const secondBrowser = createBrowser();
|
||||
const getBrowser = sinon.stub<[], Promise<Browser>>();
|
||||
getBrowser.onFirstCall().resolves(firstBrowser);
|
||||
@@ -691,7 +825,7 @@ describe('McpContext', () => {
|
||||
|
||||
await context.ensureBrowser();
|
||||
assert.ok(!context.consumeReconnectNotice());
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
|
||||
await context.ensureBrowser();
|
||||
|
||||
@@ -1460,6 +1594,239 @@ describe('McpContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onConfigChanged', () => {
|
||||
it('reads the updated CrUX setting from the shared config', async () => {
|
||||
const config = createMockServerConfig({
|
||||
performanceCrux: true,
|
||||
});
|
||||
const context = await createMockContext({config});
|
||||
|
||||
await updateConfig(config, context, {performanceCrux: false});
|
||||
|
||||
assert.strictEqual(context.isCruxEnabled(), false);
|
||||
});
|
||||
|
||||
it('reads the updated allowUnrestrictedPaths setting from the shared config', async () => {
|
||||
const config = createMockServerConfig({
|
||||
allowUnrestrictedPaths: false,
|
||||
});
|
||||
const context = await createMockContext({config});
|
||||
const outsidePath = path.resolve(os.homedir(), 'anywhere.txt');
|
||||
await assert.rejects(context.validatePath(outsidePath), /Access denied/);
|
||||
|
||||
await updateConfig(config, context, {allowUnrestrictedPaths: true});
|
||||
|
||||
assert.strictEqual(
|
||||
await context.validatePath(outsidePath),
|
||||
await resolveCanonicalPath(outsidePath),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates sourceMaps on existing pages only when changed', async () => {
|
||||
const config = createMockServerConfig({
|
||||
sourceMaps: true,
|
||||
});
|
||||
const context = await createMockContext({config});
|
||||
const mcpPage = context.getSelectedMcpPage();
|
||||
const syncSourceMapsSpy = sinon.spy(mcpPage, 'syncSourceMaps');
|
||||
|
||||
await updateConfig(config, context, {sourceMaps: true});
|
||||
sinon.assert.notCalled(syncSourceMapsSpy);
|
||||
|
||||
await updateConfig(config, context, {sourceMaps: false});
|
||||
sinon.assert.calledOnceWithExactly(syncSourceMapsSpy);
|
||||
|
||||
syncSourceMapsSpy.resetHistory();
|
||||
await updateConfig(config, context, {
|
||||
performanceCrux: false,
|
||||
sourceMaps: false,
|
||||
});
|
||||
sinon.assert.notCalled(syncSourceMapsSpy);
|
||||
|
||||
await updateConfig(config, context, {sourceMaps: true});
|
||||
sinon.assert.calledOnceWithExactly(syncSourceMapsSpy);
|
||||
});
|
||||
|
||||
it('refreshes the pages when experimentalIncludeAllPages changes', async () => {
|
||||
const config = createMockServerConfig({
|
||||
experimentalIncludeAllPages: false,
|
||||
});
|
||||
const context = await createMockContext({config});
|
||||
const snapshotSpy = sinon.spy(context, 'createPagesSnapshot');
|
||||
|
||||
await updateConfig(config, context, {experimentalIncludeAllPages: true});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(snapshotSpy);
|
||||
});
|
||||
|
||||
it('releases feature state when capabilities transition from enabled to disabled', async () => {
|
||||
const config = createMockServerConfig({
|
||||
memoryDebugging: true,
|
||||
devtoolsComments: true,
|
||||
});
|
||||
const context = await createMockContext({config});
|
||||
const releaseScreencasts = sinon
|
||||
.stub(context, 'releaseScreencasts')
|
||||
.resolves();
|
||||
const releaseHeapSnapshots = sinon.stub(context, 'releaseHeapSnapshots');
|
||||
const releasePerformanceTrace = sinon
|
||||
.stub(context, 'releasePerformanceTrace')
|
||||
.resolves();
|
||||
const releaseDevToolsComments = sinon.stub(
|
||||
context,
|
||||
'releaseDevToolsComments',
|
||||
);
|
||||
|
||||
await updateConfig(config, context, {
|
||||
categoryDebugging: false,
|
||||
categoryPerformance: false,
|
||||
memoryDebugging: false,
|
||||
});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(releaseScreencasts);
|
||||
sinon.assert.calledOnceWithExactly(releaseHeapSnapshots);
|
||||
sinon.assert.calledOnceWithExactly(releasePerformanceTrace);
|
||||
sinon.assert.calledOnceWithExactly(releaseDevToolsComments);
|
||||
});
|
||||
|
||||
it('releases feature state when switching to slim mode', async () => {
|
||||
const config = createMockServerConfig({
|
||||
memoryDebugging: true,
|
||||
devtoolsComments: true,
|
||||
});
|
||||
const context = await createMockContext({config});
|
||||
const releaseScreencasts = sinon
|
||||
.stub(context, 'releaseScreencasts')
|
||||
.resolves();
|
||||
const releaseHeapSnapshots = sinon.stub(context, 'releaseHeapSnapshots');
|
||||
const releasePerformanceTrace = sinon
|
||||
.stub(context, 'releasePerformanceTrace')
|
||||
.resolves();
|
||||
const releaseDevToolsComments = sinon.stub(
|
||||
context,
|
||||
'releaseDevToolsComments',
|
||||
);
|
||||
|
||||
await updateConfig(config, context, {slim: true});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(releaseScreencasts);
|
||||
sinon.assert.calledOnceWithExactly(releaseHeapSnapshots);
|
||||
sinon.assert.calledOnceWithExactly(releasePerformanceTrace);
|
||||
sinon.assert.calledOnceWithExactly(releaseDevToolsComments);
|
||||
});
|
||||
|
||||
it('does not release state of capabilities that stay enabled or stay disabled', async () => {
|
||||
const config = createMockServerConfig({
|
||||
memoryDebugging: false,
|
||||
devtoolsComments: true,
|
||||
});
|
||||
const context = await createMockContext({config});
|
||||
const releaseScreencasts = sinon
|
||||
.stub(context, 'releaseScreencasts')
|
||||
.resolves();
|
||||
const releaseHeapSnapshots = sinon.stub(context, 'releaseHeapSnapshots');
|
||||
const releasePerformanceTrace = sinon
|
||||
.stub(context, 'releasePerformanceTrace')
|
||||
.resolves();
|
||||
const releaseDevToolsComments = sinon.stub(
|
||||
context,
|
||||
'releaseDevToolsComments',
|
||||
);
|
||||
|
||||
await updateConfig(config, context, {
|
||||
memoryDebugging: false,
|
||||
devtoolsComments: true,
|
||||
});
|
||||
|
||||
sinon.assert.notCalled(releaseScreencasts);
|
||||
sinon.assert.notCalled(releaseHeapSnapshots);
|
||||
sinon.assert.notCalled(releasePerformanceTrace);
|
||||
sinon.assert.notCalled(releaseDevToolsComments);
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseState', () => {
|
||||
it('stops and clears the screencast recording', async () => {
|
||||
const context = await createMockContext();
|
||||
const page = context.getSelectedMcpPage();
|
||||
const recorder = sinon.createStubInstance(ScreenRecorder);
|
||||
recorder.stop.resolves();
|
||||
page.setScreenRecorder({recorder, filePath: '/tmp/video.mp4'});
|
||||
|
||||
await context.releaseScreencasts();
|
||||
|
||||
sinon.assert.calledOnceWithExactly(recorder.stop);
|
||||
assert.strictEqual(page.getScreenRecorder(), null);
|
||||
});
|
||||
|
||||
it('clears the screencast recording when stopping fails', async () => {
|
||||
const context = await createMockContext();
|
||||
const page = context.getSelectedMcpPage();
|
||||
const recorder = sinon.createStubInstance(ScreenRecorder);
|
||||
recorder.stop.rejects(new Error('ffmpeg error'));
|
||||
page.setScreenRecorder({recorder, filePath: '/tmp/video.mp4'});
|
||||
|
||||
await context.releaseScreencasts();
|
||||
|
||||
sinon.assert.calledOnceWithExactly(recorder.stop);
|
||||
assert.strictEqual(page.getScreenRecorder(), null);
|
||||
});
|
||||
|
||||
it('disposes loaded heap snapshots', async () => {
|
||||
const heapSnapshotManager = new HeapSnapshotManager();
|
||||
const context = await createMockContext({
|
||||
heapSnapshotManager,
|
||||
});
|
||||
await context.getHeapSnapshotStats(
|
||||
path.join(process.cwd(), 'tests/fixtures/example.heapsnapshot'),
|
||||
);
|
||||
|
||||
context.releaseHeapSnapshots();
|
||||
|
||||
assert.strictEqual(heapSnapshotManager.hasSnapshots(), false);
|
||||
});
|
||||
|
||||
it('does not dispose heapSnapshotManager when no snapshots are loaded', async () => {
|
||||
const heapSnapshotManager = new HeapSnapshotManager();
|
||||
const disposeSpy = sinon.spy(heapSnapshotManager, 'dispose');
|
||||
const context = await createMockContext({heapSnapshotManager});
|
||||
|
||||
context.releaseHeapSnapshots();
|
||||
|
||||
sinon.assert.notCalled(disposeSpy);
|
||||
});
|
||||
|
||||
it('stops the running performance trace and clears recorded traces', async () => {
|
||||
const {page: pptrPage, target} = createPageTarget();
|
||||
const context = await createMockContext({targets: [target]});
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
const traceResult = await parseRawTraceBuffer(
|
||||
loadTraceAsBuffer('basic-trace.json.gz'),
|
||||
);
|
||||
assert.ok(traceResultIsSuccess(traceResult));
|
||||
context.storeTraceRecording(traceResult);
|
||||
|
||||
await context.releasePerformanceTrace();
|
||||
|
||||
sinon.assert.calledOnceWithExactly(pptrPage.tracing.stop);
|
||||
assert.strictEqual(context.isRunningPerformanceTrace(), false);
|
||||
assert.deepStrictEqual(context.recordedTraces(), []);
|
||||
});
|
||||
|
||||
it('disposes comment bridges once per page when devtoolsComments is released', async () => {
|
||||
const context = await createMockContext();
|
||||
const mcpPage = context.getSelectedMcpPage();
|
||||
const disposeCommentBridgeSpy = sinon.spy(
|
||||
mcpPage,
|
||||
'disposeCommentBridge',
|
||||
);
|
||||
|
||||
context.releaseDevToolsComments();
|
||||
|
||||
sinon.assert.calledOnceWithExactly(disposeCommentBridgeSpy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensions', () => {
|
||||
const server = serverHooks();
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('performance', () => {
|
||||
describe('performance_start_trace', () => {
|
||||
it('starts a trace recording', async () => {
|
||||
await withMcpContext(async (response, context, args) => {
|
||||
context.setIsRunningPerformanceTrace(false);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(false);
|
||||
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
||||
sinon
|
||||
.stub(selectedPage, 'url')
|
||||
@@ -165,6 +165,10 @@ describe('performance', () => {
|
||||
context,
|
||||
);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
page.setIsRunningPerformanceTrace,
|
||||
true,
|
||||
);
|
||||
sinon.assert.notCalled(page.pptrPage.setCacheEnabled);
|
||||
});
|
||||
|
||||
@@ -232,7 +236,7 @@ describe('performance', () => {
|
||||
response.appendResponseLine,
|
||||
'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.',
|
||||
);
|
||||
sinon.assert.notCalled(context.setIsRunningPerformanceTrace);
|
||||
sinon.assert.notCalled(page.setIsRunningPerformanceTrace);
|
||||
sinon.assert.notCalled(page.pptrPage.goto);
|
||||
});
|
||||
|
||||
@@ -395,7 +399,7 @@ describe('performance', () => {
|
||||
|
||||
await stopTrace(args).handler({params: {}, page}, response, context);
|
||||
|
||||
sinon.assert.notCalled(context.setIsRunningPerformanceTrace);
|
||||
sinon.assert.notCalled(page.setIsRunningPerformanceTrace);
|
||||
sinon.assert.notCalled(context.storeTraceRecording);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
response.appendResponseLine,
|
||||
@@ -406,7 +410,7 @@ describe('performance', () => {
|
||||
it('will stop the trace and return trace info when a trace is running', async () => {
|
||||
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
||||
await withMcpContext(async (response, context, args) => {
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
||||
const stopTracingStub = sinon
|
||||
.stub(selectedPage.tracing, 'stop')
|
||||
@@ -430,7 +434,7 @@ describe('performance', () => {
|
||||
|
||||
it('throws an error if parsing the trace buffer fails', async () => {
|
||||
await withMcpContext(async (response, context, args) => {
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
||||
sinon
|
||||
.stub(selectedPage.tracing, 'stop')
|
||||
@@ -451,7 +455,7 @@ describe('performance', () => {
|
||||
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
||||
await withMcpContext(async (response, context, args) => {
|
||||
const filePath = 'test-trace.json';
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
||||
const stopTracingStub = sinon
|
||||
.stub(selectedPage.tracing, 'stop')
|
||||
@@ -481,7 +485,7 @@ describe('performance', () => {
|
||||
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
||||
await withMcpContext(
|
||||
async (response, context, args) => {
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
||||
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
||||
|
||||
@@ -509,7 +513,7 @@ describe('performance', () => {
|
||||
it('fetches CrUX data for desktop and includes it in the summary', async () => {
|
||||
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
||||
await withMcpContext(async (response, context, args) => {
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
||||
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
||||
|
||||
@@ -562,7 +566,7 @@ describe('performance', () => {
|
||||
const modifiedData = new TextEncoder().encode(modifiedJsonString);
|
||||
|
||||
await withMcpContext(async (response, context, args) => {
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
context.getSelectedMcpPage().setIsRunningPerformanceTrace(true);
|
||||
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
||||
sinon.stub(selectedPage.tracing, 'stop').resolves(modifiedData);
|
||||
|
||||
|
||||
Reference in new issue
Block a user