diff --git a/README.md b/README.md index 45ca0f86c..3e73a821e 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,9 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - **Emulation** (2 tools) - [`emulate`](docs/tool-reference.md#emulate) - [`resize_page`](docs/tool-reference.md#resize_page) -- **Performance** (3 tools) +- **Performance** (5 tools) + - [`coverage_start`](docs/tool-reference.md#coverage_start) + - [`coverage_stop`](docs/tool-reference.md#coverage_stop) - [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight) - [`performance_start_trace`](docs/tool-reference.md#performance_start_trace) - [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 618a1f486..18e32207e 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -21,7 +21,9 @@ - **[Emulation](#emulation)** (2 tools) - [`emulate`](#emulate) - [`resize_page`](#resize_page) -- **[Performance](#performance)** (3 tools) +- **[Performance](#performance)** (5 tools) + - [`coverage_start`](#coverage_start) + - [`coverage_stop`](#coverage_stop) - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) @@ -215,6 +217,29 @@ ## Performance +### `coverage_start` + +**Description:** Starts code coverage tracking on the selected page. This tracks which JavaScript and CSS code is actually used, helping identify unused code that could be removed to improve page performance. + +**Parameters:** + +- **includeCSS** (boolean) _(optional)_: Whether to include CSS coverage. Defaults to true. +- **includeJS** (boolean) _(optional)_: Whether to include JavaScript coverage. Defaults to true. +- **resetOnNavigation** (boolean) _(optional)_: Whether to reset coverage data on page navigation. Defaults to true. + +--- + +### `coverage_stop` + +**Description:** Stops code coverage tracking and returns a comprehensive report showing URLs, total bytes, used bytes, unused bytes, and usage percentage for each JavaScript and CSS resource. Results are sorted by unused bytes (most wasted first) and paginated. + +**Parameters:** + +- **pageIdx** (integer) _(optional)_: Page index (0-based). Use this to navigate through results. For example, pageIdx: 1 shows the next page. +- **pageSize** (integer) _(optional)_: Number of results to show per page. Maximum and default is 5 to keep output manageable. + +--- + ### `performance_analyze_insight` **Description:** Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording. diff --git a/src/McpContext.ts b/src/McpContext.ts index 11bb3d971..f90fa8c1f 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -106,6 +106,11 @@ export class McpContext implements Context { #consoleCollector: ConsoleCollector; #isRunningTrace = false; + #isRunningCoverage = false; + #coverageOptions: {includeJS: boolean; includeCSS: boolean} = { + includeJS: true, + includeCSS: true, + }; #networkConditionsMap = new WeakMap(); #cpuThrottlingRateMap = new WeakMap(); #geolocationMap = new WeakMap(); @@ -306,6 +311,22 @@ export class McpContext implements Context { return this.#isRunningTrace; } + setIsRunningCoverage(x: boolean): void { + this.#isRunningCoverage = x; + } + + isRunningCoverage(): boolean { + return this.#isRunningCoverage; + } + + setCoverageOptions(options: {includeJS: boolean; includeCSS: boolean}): void { + this.#coverageOptions = options; + } + + getCoverageOptions(): {includeJS: boolean; includeCSS: boolean} { + return this.#coverageOptions; + } + getDialog(): Dialog | undefined { return this.#dialog; } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index d017640cd..b7f957ca4 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -81,11 +81,20 @@ export interface Response { /** * Only add methods required by tools/*. */ +export interface CoverageOptions { + includeJS: boolean; + includeCSS: boolean; +} + export type Context = Readonly<{ isRunningPerformanceTrace(): boolean; setIsRunningPerformanceTrace(x: boolean): void; recordedTraces(): TraceResult[]; storeTraceRecording(result: TraceResult): void; + isRunningCoverage(): boolean; + setIsRunningCoverage(x: boolean): void; + getCoverageOptions(): CoverageOptions; + setCoverageOptions(options: CoverageOptions): void; getSelectedPage(): Page; getDialog(): Dialog | undefined; clearDialog(): void; diff --git a/src/tools/coverage.ts b/src/tools/coverage.ts new file mode 100644 index 000000000..3713d4012 --- /dev/null +++ b/src/tools/coverage.ts @@ -0,0 +1,416 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {logger} from '../logger.js'; +import {zod} from '../third_party/index.js'; +import type {CoverageEntry, Page} from '../third_party/index.js'; +import {paginate} from '../utils/pagination.js'; +import type {PaginationOptions} from '../utils/types.js'; + +import {ToolCategory} from './categories.js'; +import type {Context, Response} from './ToolDefinition.js'; +import {defineTool} from './ToolDefinition.js'; + +export interface CoverageReportEntry { + url: string; + totalBytes: number; + usedBytes: number; + unusedBytes: number; + usagePercent: number; + isExternal: boolean; +} + +export interface CoverageReport { + jsCoverage: CoverageReportEntry[]; + cssCoverage: CoverageReportEntry[]; + summary: { + totalResources: number; + totalBytes: number; + usedBytes: number; + unusedBytes: number; + overallUsagePercent: number; + }; + jsPagination?: { + showing: string; + currentPage: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + cssPagination?: { + showing: string; + currentPage: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; +} + +function isThirdParty(url: string, pageUrl: string): boolean { + try { + // Handle special cases + if (url.startsWith('data:') || url.startsWith('blob:')) { + return false; // Inline scripts/styles are internal + } + + const urlObj = new URL(url); + const pageUrlObj = new URL(pageUrl); + + // Different origin = definitely third-party (CDNs, external scripts) + if (urlObj.origin !== pageUrlObj.origin) { + return true; + } + + // Same origin - check for vendor/third-party patterns in path + const path = urlObj.pathname.toLowerCase(); + const thirdPartyPatterns = [ + '/vendor', + '/vendors', + '/node_modules', + '/npm', + '/lib/', + '/libraries', + '/deps', + '/dependencies', + 'vendor.', + 'vendors.', + 'vendor-', + 'vendors-', + '.vendor.', + '.vendors.', + 'chunk.vendors', + 'chunk.libs', + ]; + + return thirdPartyPatterns.some(pattern => path.includes(pattern)); + } catch { + // If URL parsing fails, assume internal + return false; + } +} + +function calculateCoverageEntry( + entry: CoverageEntry, + pageUrl: string, +): CoverageReportEntry { + const totalBytes = entry.text.length; + let usedBytes = 0; + for (const range of entry.ranges) { + usedBytes += range.end - range.start; + } + const unusedBytes = totalBytes - usedBytes; + const usagePercent = totalBytes > 0 ? (usedBytes / totalBytes) * 100 : 0; + + return { + url: entry.url, + totalBytes, + usedBytes, + unusedBytes, + usagePercent, + isExternal: isThirdParty(entry.url, pageUrl), + }; +} + +function formatCoverageReport(report: CoverageReport): string { + const lines: string[] = []; + + lines.push('## Coverage Report'); + lines.push(''); + lines.push('### Summary'); + lines.push(`- Total resources: ${report.summary.totalResources}`); + lines.push(`- Total bytes: ${report.summary.totalBytes.toLocaleString()}`); + lines.push(`- Used bytes: ${report.summary.usedBytes.toLocaleString()}`); + lines.push(`- Unused bytes: ${report.summary.unusedBytes.toLocaleString()}`); + lines.push( + `- Overall usage: ${report.summary.overallUsagePercent.toFixed(1)}%`, + ); + lines.push(''); + + if (report.jsCoverage.length > 0) { + lines.push('### JavaScript Coverage'); + lines.push(''); + if (report.jsPagination) { + lines.push(report.jsPagination.showing); + if (report.jsPagination.hasNextPage) { + lines.push(`Next page: ${report.jsPagination.currentPage + 1}`); + } + if (report.jsPagination.hasPreviousPage) { + lines.push(`Previous page: ${report.jsPagination.currentPage - 1}`); + } + lines.push(''); + } + lines.push( + '| URL | Type | Total Bytes | Used Bytes | Unused Bytes | Usage % |', + ); + lines.push( + '|-----|------|-------------|------------|--------------|---------|', + ); + for (const entry of report.jsCoverage) { + const shortUrl = + entry.url.length > 50 ? '...' + entry.url.slice(-47) : entry.url; + const type = entry.isExternal ? '3rd-party' : 'Internal'; + lines.push( + `| ${shortUrl} | ${type} | ${entry.totalBytes.toLocaleString()} | ${entry.usedBytes.toLocaleString()} | ${entry.unusedBytes.toLocaleString()} | ${entry.usagePercent.toFixed(1)}% |`, + ); + } + lines.push(''); + } + + if (report.cssCoverage.length > 0) { + lines.push('### CSS Coverage'); + lines.push(''); + if (report.cssPagination) { + lines.push(report.cssPagination.showing); + if (report.cssPagination.hasNextPage) { + lines.push(`Next page: ${report.cssPagination.currentPage + 1}`); + } + if (report.cssPagination.hasPreviousPage) { + lines.push(`Previous page: ${report.cssPagination.currentPage - 1}`); + } + lines.push(''); + } + lines.push( + '| URL | Type | Total Bytes | Used Bytes | Unused Bytes | Usage % |', + ); + lines.push( + '|-----|------|-------------|------------|--------------|---------|', + ); + for (const entry of report.cssCoverage) { + const shortUrl = + entry.url.length > 50 ? '...' + entry.url.slice(-47) : entry.url; + const type = entry.isExternal ? '3rd-party' : 'Internal'; + lines.push( + `| ${shortUrl} | ${type} | ${entry.totalBytes.toLocaleString()} | ${entry.usedBytes.toLocaleString()} | ${entry.unusedBytes.toLocaleString()} | ${entry.usagePercent.toFixed(1)}% |`, + ); + } + lines.push(''); + } + + return lines.join('\n'); +} + +export const startCoverage = defineTool({ + name: 'coverage_start', + description: + 'Starts code coverage tracking on the selected page. This tracks which JavaScript and CSS code is actually used, helping identify unused code that could be removed to improve page performance.', + annotations: { + category: ToolCategory.PERFORMANCE, + readOnlyHint: false, + }, + schema: { + resetOnNavigation: zod + .boolean() + .default(true) + .optional() + .describe( + 'Whether to reset coverage data on page navigation. Defaults to true.', + ), + includeJS: zod + .boolean() + .default(true) + .optional() + .describe('Whether to include JavaScript coverage. Defaults to true.'), + includeCSS: zod + .boolean() + .default(true) + .optional() + .describe('Whether to include CSS coverage. Defaults to true.'), + }, + handler: async (request, response, context) => { + if (context.isRunningCoverage()) { + response.appendResponseLine( + 'Error: coverage tracking is already running. Use coverage_stop to stop it. Only one coverage session can be running at any given time.', + ); + return; + } + + const includeJS = request.params.includeJS ?? true; + const includeCSS = request.params.includeCSS ?? true; + + if (!includeJS && !includeCSS) { + response.appendResponseLine( + 'Error: at least one of includeJS or includeCSS must be true.', + ); + return; + } + + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS, includeCSS}); + + const page = context.getSelectedPage(); + const resetOnNavigation = request.params.resetOnNavigation ?? true; + + try { + const promises: Array> = []; + + if (includeJS) { + promises.push(page.coverage.startJSCoverage({resetOnNavigation})); + } + if (includeCSS) { + promises.push(page.coverage.startCSSCoverage({resetOnNavigation})); + } + + await Promise.all(promises); + + const types: string[] = []; + if (includeJS) types.push('JavaScript'); + if (includeCSS) types.push('CSS'); + + response.appendResponseLine( + `Coverage tracking started for ${types.join(' and ')}. Use coverage_stop to stop tracking and get the report.`, + ); + } catch (e) { + context.setIsRunningCoverage(false); + const errorText = e instanceof Error ? e.message : JSON.stringify(e); + logger(`Error starting coverage: ${errorText}`); + response.appendResponseLine( + `Error starting coverage tracking: ${errorText}`, + ); + } + }, +}); + +export const stopCoverage = defineTool({ + name: 'coverage_stop', + description: + 'Stops code coverage tracking and returns a comprehensive report showing URLs, total bytes, used bytes, unused bytes, and usage percentage for each JavaScript and CSS resource. Results are sorted by unused bytes (most wasted first) and paginated.', + annotations: { + category: ToolCategory.PERFORMANCE, + readOnlyHint: true, + }, + schema: { + pageSize: zod + .number() + .int() + .positive() + .max(5) + .default(5) + .optional() + .describe( + 'Number of results to show per page. Maximum and default is 5 to keep output manageable.', + ), + pageIdx: zod + .number() + .int() + .min(0) + .default(0) + .optional() + .describe( + 'Page index (0-based). Use this to navigate through results. For example, pageIdx: 1 shows the next page.', + ), + }, + handler: async (request, response, context) => { + if (!context.isRunningCoverage()) { + response.appendResponseLine('Error: No coverage tracking is running.'); + response.appendResponseLine(''); + response.appendResponseLine('To use coverage tracking:'); + response.appendResponseLine( + '1. First call coverage_start to begin tracking', + ); + response.appendResponseLine('2. Navigate or interact with the page'); + response.appendResponseLine( + '3. Then call coverage_stop to get the report', + ); + return; + } + + const page = context.getSelectedPage(); + const pagination: PaginationOptions = { + pageSize: request.params.pageSize ?? 5, + pageIdx: request.params.pageIdx ?? 0, + }; + await stopCoverageAndAppendOutput(page, response, context, pagination); + }, +}); + +async function stopCoverageAndAppendOutput( + page: Page, + response: Response, + context: Context, + pagination: PaginationOptions, +): Promise { + try { + const options = context.getCoverageOptions(); + const jsCoverage: CoverageReportEntry[] = []; + const cssCoverage: CoverageReportEntry[] = []; + const pageUrl = page.url(); + + if (options.includeJS) { + const jsEntries = await page.coverage.stopJSCoverage(); + for (const entry of jsEntries) { + jsCoverage.push(calculateCoverageEntry(entry, pageUrl)); + } + } + + if (options.includeCSS) { + const cssEntries = await page.coverage.stopCSSCoverage(); + for (const entry of cssEntries) { + cssCoverage.push(calculateCoverageEntry(entry, pageUrl)); + } + } + + // Sort by unused bytes descending (most unused first) + jsCoverage.sort((a, b) => b.unusedBytes - a.unusedBytes); + cssCoverage.sort((a, b) => b.unusedBytes - a.unusedBytes); + + // Calculate summary (based on ALL entries, not just the paginated ones) + const allEntries = [...jsCoverage, ...cssCoverage]; + const totalBytes = allEntries.reduce((sum, e) => sum + e.totalBytes, 0); + const usedBytes = allEntries.reduce((sum, e) => sum + e.usedBytes, 0); + const unusedBytes = allEntries.reduce((sum, e) => sum + e.unusedBytes, 0); + const overallUsagePercent = + totalBytes > 0 ? (usedBytes / totalBytes) * 100 : 0; + + // Apply pagination + const jsPaginationResult = paginate(jsCoverage, pagination); + const cssPaginationResult = paginate(cssCoverage, pagination); + + const report: CoverageReport = { + jsCoverage: [...jsPaginationResult.items], + cssCoverage: [...cssPaginationResult.items], + summary: { + totalResources: allEntries.length, + totalBytes, + usedBytes, + unusedBytes, + overallUsagePercent, + }, + jsPagination: + jsCoverage.length > 0 + ? { + showing: `Showing ${jsPaginationResult.startIndex + 1}-${jsPaginationResult.endIndex} of ${jsCoverage.length} JS files (Page ${jsPaginationResult.currentPage + 1} of ${jsPaginationResult.totalPages})`, + currentPage: jsPaginationResult.currentPage, + totalPages: jsPaginationResult.totalPages, + hasNextPage: jsPaginationResult.hasNextPage, + hasPreviousPage: jsPaginationResult.hasPreviousPage, + } + : undefined, + cssPagination: + cssCoverage.length > 0 + ? { + showing: `Showing ${cssPaginationResult.startIndex + 1}-${cssPaginationResult.endIndex} of ${cssCoverage.length} CSS files (Page ${cssPaginationResult.currentPage + 1} of ${cssPaginationResult.totalPages})`, + currentPage: cssPaginationResult.currentPage, + totalPages: cssPaginationResult.totalPages, + hasNextPage: cssPaginationResult.hasNextPage, + hasPreviousPage: cssPaginationResult.hasPreviousPage, + } + : undefined, + }; + + response.appendResponseLine('Coverage tracking has been stopped.'); + response.appendResponseLine(''); + response.appendResponseLine(formatCoverageReport(report)); + } catch (e) { + const errorText = e instanceof Error ? e.message : JSON.stringify(e); + logger(`Error stopping coverage: ${errorText}`); + response.appendResponseLine( + 'An error occurred generating the coverage report:', + ); + response.appendResponseLine(errorText); + } finally { + context.setIsRunningCoverage(false); + } +} diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 227fb0d42..b68befe55 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import * as consoleTools from './console.js'; +import * as coverageTools from './coverage.js'; import * as emulationTools from './emulation.js'; import * as inputTools from './input.js'; import * as networkTools from './network.js'; @@ -16,6 +17,7 @@ import type {ToolDefinition} from './ToolDefinition.js'; const tools = [ ...Object.values(consoleTools), + ...Object.values(coverageTools), ...Object.values(emulationTools), ...Object.values(inputTools), ...Object.values(networkTools), diff --git a/tests/tools/coverage.test.ts b/tests/tools/coverage.test.ts new file mode 100644 index 000000000..bd94d9581 --- /dev/null +++ b/tests/tools/coverage.test.ts @@ -0,0 +1,443 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it, afterEach} from 'node:test'; + +import sinon from 'sinon'; + +import {startCoverage, stopCoverage} from '../../src/tools/coverage.js'; +import {withMcpContext} from '../utils.js'; + +describe('coverage', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('coverage_start', () => { + it('starts JS and CSS coverage tracking', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(false); + const selectedPage = context.getSelectedPage(); + const startJSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startJSCoverage', + ); + const startCSSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startCSSCoverage', + ); + + await startCoverage.handler( + { + params: { + resetOnNavigation: true, + includeJS: true, + includeCSS: true, + }, + }, + response, + context, + ); + + sinon.assert.calledOnce(startJSCoverageStub); + sinon.assert.calledOnce(startCSSCoverageStub); + assert.ok(context.isRunningCoverage()); + assert.ok( + response.responseLines + .join('\n') + .match(/Coverage tracking started for JavaScript and CSS/), + ); + }); + }); + + it('starts only JS coverage when includeCSS is false', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(false); + const selectedPage = context.getSelectedPage(); + const startJSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startJSCoverage', + ); + const startCSSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startCSSCoverage', + ); + + await startCoverage.handler( + {params: {includeJS: true, includeCSS: false}}, + response, + context, + ); + + sinon.assert.calledOnce(startJSCoverageStub); + sinon.assert.notCalled(startCSSCoverageStub); + assert.ok(context.isRunningCoverage()); + assert.ok( + response.responseLines + .join('\n') + .match(/Coverage tracking started for JavaScript/), + ); + }); + }); + + it('starts only CSS coverage when includeJS is false', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(false); + const selectedPage = context.getSelectedPage(); + const startJSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startJSCoverage', + ); + const startCSSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startCSSCoverage', + ); + + await startCoverage.handler( + {params: {includeJS: false, includeCSS: true}}, + response, + context, + ); + + sinon.assert.notCalled(startJSCoverageStub); + sinon.assert.calledOnce(startCSSCoverageStub); + assert.ok(context.isRunningCoverage()); + assert.ok( + response.responseLines + .join('\n') + .match(/Coverage tracking started for CSS/), + ); + }); + }); + + it('errors if both includeJS and includeCSS are false', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(false); + const selectedPage = context.getSelectedPage(); + const startJSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startJSCoverage', + ); + const startCSSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startCSSCoverage', + ); + + await startCoverage.handler( + {params: {includeJS: false, includeCSS: false}}, + response, + context, + ); + + sinon.assert.notCalled(startJSCoverageStub); + sinon.assert.notCalled(startCSSCoverageStub); + assert.ok(!context.isRunningCoverage()); + assert.ok( + response.responseLines + .join('\n') + .match(/at least one of includeJS or includeCSS must be true/), + ); + }); + }); + + it('errors if coverage is already running', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + const selectedPage = context.getSelectedPage(); + const startJSCoverageStub = sinon.stub( + selectedPage.coverage, + 'startJSCoverage', + ); + + await startCoverage.handler( + {params: {includeJS: true, includeCSS: true}}, + response, + context, + ); + + sinon.assert.notCalled(startJSCoverageStub); + assert.ok( + response.responseLines + .join('\n') + .match(/coverage tracking is already running/), + ); + }); + }); + }); + + describe('coverage_stop', () => { + it('errors if no coverage is running', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(false); + + await stopCoverage.handler({params: {}}, response, context); + + const output = response.responseLines.join('\n'); + assert.ok(output.match(/No coverage tracking is running/)); + assert.ok(output.match(/First call coverage_start/)); + }); + }); + + it('stops coverage and returns paginated report', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS: true, includeCSS: true}); + const selectedPage = context.getSelectedPage(); + + const mockJSCoverage = [ + { + url: 'https://example.com/script.js', + text: 'function test() { console.log("hello"); }', + ranges: [{start: 0, end: 20}], + }, + ]; + + const mockCSSCoverage = [ + { + url: 'https://example.com/style.css', + text: '.class { color: red; } .unused { color: blue; }', + ranges: [{start: 0, end: 22}], + }, + ]; + + sinon + .stub(selectedPage.coverage, 'stopJSCoverage') + .resolves(mockJSCoverage); + sinon + .stub(selectedPage.coverage, 'stopCSSCoverage') + .resolves(mockCSSCoverage); + + await stopCoverage.handler( + {params: {pageSize: 5, pageIdx: 0}}, + response, + context, + ); + + const output = response.responseLines.join('\n'); + assert.ok(output.match(/Coverage tracking has been stopped/)); + assert.ok(output.match(/## Coverage Report/)); + assert.ok(output.match(/### Summary/)); + assert.ok(output.match(/Total resources: 2/)); + assert.ok(output.match(/### JavaScript Coverage/)); + assert.ok(output.match(/### CSS Coverage/)); + assert.ok(output.match(/script\.js/)); + assert.ok(output.match(/style\.css/)); + assert.ok(output.match(/Showing 1-1 of 1 JS files/)); + assert.ok(output.match(/Showing 1-1 of 1 CSS files/)); + assert.strictEqual(context.isRunningCoverage(), false); + }); + }); + + it('stops only JS coverage when CSS was not enabled', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS: true, includeCSS: false}); + const selectedPage = context.getSelectedPage(); + + const mockJSCoverage = [ + { + url: 'https://example.com/script.js', + text: 'function test() {}', + ranges: [{start: 0, end: 10}], + }, + ]; + + const stopJSStub = sinon + .stub(selectedPage.coverage, 'stopJSCoverage') + .resolves(mockJSCoverage); + const stopCSSStub = sinon.stub( + selectedPage.coverage, + 'stopCSSCoverage', + ); + + await stopCoverage.handler( + {params: {pageSize: 5, pageIdx: 0}}, + response, + context, + ); + + sinon.assert.calledOnce(stopJSStub); + sinon.assert.notCalled(stopCSSStub); + + const output = response.responseLines.join('\n'); + assert.ok(output.match(/### JavaScript Coverage/)); + assert.ok(!output.match(/### CSS Coverage/)); + }); + }); + + it('calculates usage percentage correctly', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS: true, includeCSS: false}); + const selectedPage = context.getSelectedPage(); + + // 100 bytes total, 50 bytes used = 50% usage + const mockJSCoverage = [ + { + url: 'https://example.com/script.js', + text: 'x'.repeat(100), + ranges: [{start: 0, end: 50}], + }, + ]; + + sinon + .stub(selectedPage.coverage, 'stopJSCoverage') + .resolves(mockJSCoverage); + + await stopCoverage.handler( + {params: {pageSize: 5, pageIdx: 0}}, + response, + context, + ); + + const output = response.responseLines.join('\n'); + assert.ok(output.match(/Overall usage: 50\.0%/)); + assert.ok(output.match(/Used bytes: 50/)); + assert.ok(output.match(/Unused bytes: 50/)); + }); + }); + + it('sorts results by unused bytes descending', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS: true, includeCSS: false}); + const selectedPage = context.getSelectedPage(); + + const mockJSCoverage = [ + { + url: 'https://example.com/small-waste.js', + text: 'x'.repeat(100), + ranges: [{start: 0, end: 90}], // 10 bytes unused + }, + { + url: 'https://example.com/big-waste.js', + text: 'x'.repeat(100), + ranges: [{start: 0, end: 20}], // 80 bytes unused + }, + ]; + + sinon + .stub(selectedPage.coverage, 'stopJSCoverage') + .resolves(mockJSCoverage); + + await stopCoverage.handler( + {params: {pageSize: 5, pageIdx: 0}}, + response, + context, + ); + + const output = response.responseLines.join('\n'); + // big-waste.js should appear before small-waste.js + const bigWasteIndex = output.indexOf('big-waste.js'); + const smallWasteIndex = output.indexOf('small-waste.js'); + assert.ok( + bigWasteIndex < smallWasteIndex, + 'Results should be sorted by unused bytes descending', + ); + }); + }); + + it('paginates results correctly', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS: true, includeCSS: false}); + const selectedPage = context.getSelectedPage(); + + // Create 7 JS files with descending unused amounts (file0 = most unused) + const mockJSCoverage = Array.from({length: 7}, (_, i) => ({ + url: `https://example.com/file${i}.js`, + text: 'x'.repeat(100), + ranges: [{start: 0, end: 10 + i * 10}], // file0 has 90 unused, file6 has 30 unused + })); + + sinon + .stub(selectedPage.coverage, 'stopJSCoverage') + .resolves(mockJSCoverage); + + // Get first page (pageSize: 5) + await stopCoverage.handler( + {params: {pageSize: 5, pageIdx: 0}}, + response, + context, + ); + + const output = response.responseLines.join('\n'); + assert.ok(output.match(/Showing 1-5 of 7 JS files/)); + assert.ok(output.match(/Next page: 1/)); + // Should show first 5 files (sorted by unused bytes descending) + assert.ok(output.match(/file0\.js/)); // Most unused + assert.ok(output.match(/file4\.js/)); + assert.ok(!output.match(/file5\.js/)); // Should not show file 5 + assert.ok(!output.match(/file6\.js/)); // Should not show file 6 + }); + }); + + it('enforces maximum page size of 5', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS: true, includeCSS: false}); + const selectedPage = context.getSelectedPage(); + + const mockJSCoverage = Array.from({length: 10}, (_, i) => ({ + url: `https://example.com/file${i}.js`, + text: 'x'.repeat(100), + ranges: [{start: 0, end: 10 + i * 10}], + })); + + sinon + .stub(selectedPage.coverage, 'stopJSCoverage') + .resolves(mockJSCoverage); + + // Try to request pageSize: 10, should be rejected by schema validation + // Since zod will throw an error, we expect the handler to not be called + // Instead, test with pageSize: 5 to verify it works + await stopCoverage.handler( + {params: {pageSize: 5, pageIdx: 0}}, + response, + context, + ); + + const output = response.responseLines.join('\n'); + assert.ok(output.match(/Showing 1-5 of 10 JS files/)); + }); + }); + + it('shows second page when pageIdx is 1', async () => { + await withMcpContext(async (response, context) => { + context.setIsRunningCoverage(true); + context.setCoverageOptions({includeJS: true, includeCSS: false}); + const selectedPage = context.getSelectedPage(); + + const mockJSCoverage = Array.from({length: 7}, (_, i) => ({ + url: `https://example.com/file${i}.js`, + text: 'x'.repeat(100), + ranges: [{start: 0, end: 10 + i * 10}], // file0 has 90 unused, file6 has 30 unused + })); + + sinon + .stub(selectedPage.coverage, 'stopJSCoverage') + .resolves(mockJSCoverage); + + response.resetResponseLineForTesting(); + context.setIsRunningCoverage(true); + + await stopCoverage.handler( + {params: {pageSize: 5, pageIdx: 1}}, + response, + context, + ); + + const output = response.responseLines.join('\n'); + assert.ok(output.match(/Showing 6-7 of 7 JS files/)); + assert.ok(output.match(/Previous page: 0/)); + assert.ok(!output.match(/file0\.js/)); // Should not show files 0-4 (most unused) + assert.ok(output.match(/file5\.js/)); // Should show files 5-6 (less unused) + assert.ok(output.match(/file6\.js/)); + }); + }); + }); +});