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:
Nikolay Vitkov authored and GitHub committed 2026-10-19 15:33:08 +00:00
1 parent b1e6e6850d
commit c0140d1b4a
6 files changed
+595 -53

No files matched your search

+163 -25
View File
@@ -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()) {
+12
View File
@@ -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;
+29 -8
View File
@@ -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;
}
+4 -4
View File
@@ -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
View File
@@ -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();
+13 -9
View File
@@ -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);