Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import {Locator} from './third_party/index.js';
import {PredefinedNetworkConditions} from './third_party/index.js';
import {listPages} from './tools/pages.js';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
import type {
Context,
DevToolsData,
SupportedExtensions,
} from './tools/ToolDefinition.js';
import type {TraceResult} from './trace-processing/parse.js';
import type {
EmulationSettings,
Expand All @@ -46,7 +50,7 @@ import {
ExtensionRegistry,
type InstalledExtension,
} from './utils/ExtensionRegistry.js';
import {saveTemporaryFile} from './utils/files.js';
import {ensureExtension, saveTemporaryFile} from './utils/files.js';
import {getNetworkMultiplierFromString} from './WaitForHelper.js';

interface McpContextOptions {
Expand Down Expand Up @@ -801,10 +805,14 @@ export class McpContext implements Context {
}
async saveFile(
data: Uint8Array<ArrayBufferLike>,
filename: string,
clientProvidedFilePath: string,
extension: SupportedExtensions,
): Promise<{filename: string}> {
try {
const filePath = path.resolve(filename);
const filePath = ensureExtension(
path.resolve(clientProvidedFilePath),
extension,
);
await fs.mkdir(path.dirname(filePath), {recursive: true});
await fs.writeFile(filePath, data);
return {filename: filePath};
Expand Down
11 changes: 7 additions & 4 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,11 +403,12 @@ export class McpResponse implements Response {
if (textSnapshot) {
const formatter = new SnapshotFormatter(textSnapshot);
if (this.#snapshotParams.filePath) {
await context.saveFile(
const result = await context.saveFile(
new TextEncoder().encode(formatter.toString()),
this.#snapshotParams.filePath,
'.txt',
);
snapshot = this.#snapshotParams.filePath;
snapshot = result.filename;
} else {
snapshot = formatter;
}
Expand All @@ -429,7 +430,8 @@ export class McpResponse implements Response {
fetchData: true,
requestFilePath: this.#attachedNetworkRequestOptions?.requestFilePath,
responseFilePath: this.#attachedNetworkRequestOptions?.responseFilePath,
saveFile: (data, filename) => context.saveFile(data, filename),
saveFile: (data, filename, extension) =>
context.saveFile(data, filename, extension),
redactNetworkHeaders: this.#redactNetworkHeaders,
});
detailedNetworkRequest = formatter;
Expand Down Expand Up @@ -573,7 +575,8 @@ export class McpResponse implements Response {
context.getNetworkRequestStableId(request) ===
this.#networkRequestsOptions?.networkRequestIdInDevToolsUI,
fetchData: false,
saveFile: (data, filename) => context.saveFile(data, filename),
saveFile: (data, filename, extension) =>
context.saveFile(data, filename, extension),
redactNetworkHeaders: this.#redactNetworkHeaders,
}),
),
Expand Down
14 changes: 10 additions & 4 deletions src/formatters/NetworkFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface NetworkFormatterOptions {
saveFile?: (
data: Uint8Array<ArrayBufferLike>,
filename: string,
extension: '.network-request' | '.network-response',
) => Promise<{filename: string}>;
redactNetworkHeaders: boolean;
}
Expand Down Expand Up @@ -88,11 +89,12 @@ export class NetworkFormatter {
throw new Error('saveFile is not provided');
}
if (data) {
await this.#options.saveFile(
const result = await this.#options.saveFile(
Buffer.from(data),
this.#options.requestFilePath,
'.network-request',
);
this.#requestBodyFilePath = this.#options.requestFilePath;
this.#requestBodyFilePath = result.filename;
} else {
this.#requestBody = requestBodyNotAvailableMessage;
}
Expand All @@ -119,8 +121,12 @@ export class NetworkFormatter {
if (!this.#options.saveFile) {
throw new Error('saveFile is not provided');
}
await this.#options.saveFile(buffer, this.#options.responseFilePath);
this.#responseBodyFilePath = this.#options.responseFilePath;
const result = await this.#options.saveFile(
buffer,
this.#options.responseFilePath,
'.network-response',
);
this.#responseBodyFilePath = result.filename;
} catch {
// Flatten error handling for buffer() failure and save failure
}
Expand Down
15 changes: 14 additions & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ export interface Response {
setListInPageTools(): void;
}

export type SupportedExtensions =
| '.png'
| '.jpeg'
| '.webp'
| '.json'
| '.network-response'
| '.network-request'
| '.html'
| '.txt'
| '.csv'
| '.json.gz';

/**
* Only add methods required by tools/*.
*/
Expand Down Expand Up @@ -170,7 +182,8 @@ export type Context = Readonly<{
): Promise<{filepath: string}>;
saveFile(
data: Uint8Array<ArrayBufferLike>,
filename: string,
clientProvidedFilePath: string,
extension: SupportedExtensions,
): Promise<{filename: string}>;
waitForTextOnPage(
text: string[],
Expand Down
8 changes: 6 additions & 2 deletions src/tools/lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,12 @@ export const lighthouseAudit = definePageTool({
const report = generateReport(lhr, format);
const data = encoder.encode(report);
if (outputDirPath) {
const reportPath = path.join(outputDirPath, `report.${format}`);
const {filename} = await context.saveFile(data, reportPath);
const reportPath = path.join(outputDirPath, `report`);
const {filename} = await context.saveFile(
data,
reportPath,
`.${format}`,
);
reportPaths.push(filename);
} else {
const {filepath} = await context.saveTemporaryFile(
Expand Down
6 changes: 5 additions & 1 deletion src/tools/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,11 @@ async function stopTracingAndAppendOutput(
});
});
}
const file = await context.saveFile(dataToWrite, filePath);
const file = await context.saveFile(
dataToWrite,
filePath,
filePath.endsWith('.gz') ? '.json.gz' : '.json',
);
response.appendResponseLine(
`The raw trace data was saved to ${file.filename}.`,
);
Expand Down
3 changes: 2 additions & 1 deletion src/tools/screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from 'node:path';

import {zod} from '../third_party/index.js';
import type {ScreenRecorder} from '../third_party/index.js';
import {ensureExtension} from '../utils/files.js';

import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
Expand Down Expand Up @@ -46,7 +47,7 @@ export const startScreencast = definePageTool({
}

const filePath = request.params.path ?? (await generateTempFilePath());
const resolvedPath = path.resolve(filePath);
const resolvedPath = ensureExtension(path.resolve(filePath), '.mp4');

const page = request.page;

Expand Down
8 changes: 6 additions & 2 deletions src/tools/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@ export const screenshot = definePageTool({
}

if (request.params.filePath) {
const file = await context.saveFile(screenshot, request.params.filePath);
response.appendResponseLine(`Saved screenshot to ${file.filename}.`);
const result = await context.saveFile(
screenshot,
request.params.filePath,
`.${format}`,
);
response.appendResponseLine(`Saved screenshot to ${result.filename}.`);
} else if (screenshot.length >= 2_000_000) {
const {filepath} = await context.saveTemporaryFile(
screenshot,
Expand Down
8 changes: 8 additions & 0 deletions src/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,11 @@ export async function saveTemporaryFile(
throw new Error('Could not save a file', {cause: err});
}
}

export function ensureExtension(filepath: string, extension: string): string {
Comment thread
OrKoN marked this conversation as resolved.
Outdated
const normalizedExtension = extension.startsWith('.')
? extension
: `.${extension}`;
const ext = path.extname(filepath);
return filepath.slice(0, filepath.length - ext.length) + normalizedExtension;
}
2 changes: 1 addition & 1 deletion tests/McpResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ describe('McpResponse', () => {
});

it('saves snapshot to file and returns structured content', async t => {
const filePath = join(tmpdir(), 'test-screenshot.png');
const filePath = join(tmpdir(), 'test-snapshot.txt');
try {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
Expand Down
43 changes: 43 additions & 0 deletions tests/utils/files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2025 Google LLC
Comment thread
OrKoN marked this conversation as resolved.
Outdated
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';
import {describe, it} from 'node:test';

import {ensureExtension} from '../../src/utils/files.js';

describe('ensureExtension', () => {
it('should add an extension to a filename without one', () => {
assert.strictEqual(ensureExtension('filename', '.txt'), 'filename.txt');
});

it('should replace an existing extension', () => {
assert.strictEqual(ensureExtension('filename.jpg', '.txt'), 'filename.txt');
});

it('should handle extension without a leading dot', () => {
assert.strictEqual(ensureExtension('filename', 'txt'), 'filename.txt');
});

it('should not add a second dot if already present', () => {
assert.strictEqual(ensureExtension('filename.txt', '.txt'), 'filename.txt');
});

it('should handle paths with directories', () => {
assert.strictEqual(
ensureExtension('/path/to/file.jpg', '.png'),
'/path/to/file.png',
);
});

it('should handle hidden files (starting with dot)', () => {
assert.strictEqual(ensureExtension('.bashrc', '.txt'), '.bashrc.txt');
});

it('should handle complex extensions (like .tar.gz) - path.extname only gets the last one', () => {
assert.strictEqual(ensureExtension('file.tar.gz', '.zip'), 'file.tar.zip');
});
});
Loading