/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; import {startScreencast, stopScreencast} from '../../src/tools/screencast.js'; import {withMcpContext} from '../utils.js'; function createMockRecorder() { return { stop: sinon.stub().resolves(), }; } describe('screencast', () => { afterEach(() => { sinon.restore(); }); describe('screencast_start', () => { it('starts a screencast recording with filePath', async () => { await withMcpContext(async (response, context) => { const mockRecorder = createMockRecorder(); const selectedPage = context.getSelectedMcpPage().pptrPage; const screencastStub = sinon .stub(selectedPage, 'screencast') .resolves(mockRecorder as never); await startScreencast().handler( { params: {filePath: path.join(os.tmpdir(), 'test-recording.mp4')}, page: context.getSelectedMcpPage(), }, response, context, ); sinon.assert.calledOnce(screencastStub); const callArgs = screencastStub.firstCall.args[0]; assert.ok(callArgs); assert.ok(callArgs.path?.endsWith('test-recording.mp4')); assert.ok(context.getScreenRecorder() !== null); assert.ok( response.responseLines .join('\n') .includes('Screencast recording started'), ); }); }); it('records WebM for an uppercase extension (case-insensitive)', async () => { await withMcpContext(async (response, context) => { const mockRecorder = createMockRecorder(); const selectedPage = context.getSelectedMcpPage().pptrPage; const screencastStub = sinon .stub(selectedPage, 'screencast') .resolves(mockRecorder as never); await startScreencast().handler( { params: {filePath: path.join(os.tmpdir(), 'test-recording.WEBM')}, page: context.getSelectedMcpPage(), }, response, context, ); sinon.assert.calledOnce(screencastStub); const callArgs = screencastStub.firstCall.args[0]; assert.ok(callArgs); assert.strictEqual(callArgs.format, 'webm'); assert.ok(callArgs.path?.endsWith('.webm')); }); }); it('rejects an unsupported extension instead of silently using mp4', async () => { await withMcpContext(async (response, context) => { const selectedPage = context.getSelectedMcpPage().pptrPage; const screencastStub = sinon.stub(selectedPage, 'screencast'); await assert.rejects( startScreencast().handler( { params: {filePath: path.join(os.tmpdir(), 'recording.avi')}, page: context.getSelectedMcpPage(), }, response, context, ), /Unsupported screencast file extension/, ); sinon.assert.notCalled(screencastStub); assert.strictEqual(context.getScreenRecorder(), null); }); }); it('starts a screencast recording with temp file when no filePath', async () => { await withMcpContext(async (response, context) => { const mockRecorder = createMockRecorder(); const selectedPage = context.getSelectedMcpPage().pptrPage; const screencastStub = sinon .stub(selectedPage, 'screencast') .resolves(mockRecorder as never); await startScreencast().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, ); sinon.assert.calledOnce(screencastStub); const callArgs = screencastStub.firstCall.args[0]; assert.ok(callArgs); assert.ok(callArgs.path?.endsWith('.mp4')); assert.ok(context.getScreenRecorder() !== null); }); }); it('errors if a recording is already active', async () => { await withMcpContext(async (response, context) => { const mockRecorder = createMockRecorder(); context.setScreenRecorder({ recorder: mockRecorder as never, filePath: path.join(os.tmpdir(), 'existing.mp4'), }); const selectedPage = context.getSelectedMcpPage().pptrPage; const screencastStub = sinon.stub(selectedPage, 'screencast'); await startScreencast().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, ); sinon.assert.notCalled(screencastStub); assert.ok( response.responseLines .join('\n') .includes('a screencast recording is already in progress'), ); }); }); it('provides a clear error when ffmpeg is not found', async () => { await withMcpContext(async (response, context) => { const selectedPage = context.getSelectedMcpPage().pptrPage; const error = new Error('spawn ffmpeg ENOENT'); sinon.stub(selectedPage, 'screencast').rejects(error); await assert.rejects( startScreencast().handler( { params: {filePath: path.join(os.tmpdir(), 'test.mp4')}, page: context.getSelectedMcpPage(), }, response, context, ), /ffmpeg is required for screencast recording/, ); assert.strictEqual(context.getScreenRecorder(), null); }); }); it('cleans up the generated temp directory if recording fails to start', async () => { await withMcpContext(async (response, context) => { const selectedPage = context.getSelectedMcpPage().pptrPage; const screencastStub = sinon .stub(selectedPage, 'screencast') .rejects(new Error('spawn ffmpeg ENOENT')); await assert.rejects( startScreencast().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, ), /ffmpeg is required for screencast recording/, ); // The temp directory generateTempFilePath() created must be removed. const tempPath = screencastStub.firstCall.args[0]?.path as string; assert.ok(tempPath); await assert.rejects(fs.access(path.dirname(tempPath))); assert.strictEqual(context.getScreenRecorder(), null); }); }); it('passes ffmpegPath from args to puppeteer', async () => { await withMcpContext(async (response, context) => { const mockRecorder = createMockRecorder(); const selectedPage = context.getSelectedMcpPage().pptrPage; const screencastStub = sinon .stub(selectedPage, 'screencast') .resolves(mockRecorder as never); const experimentalFfmpegPath = '/custom/path/to/ffmpeg'; await startScreencast({ experimentalFfmpegPath, } as ParsedArguments).handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, ); sinon.assert.calledOnce(screencastStub); const callArgs = screencastStub.firstCall.args[0]; assert.strictEqual(callArgs?.ffmpegPath, experimentalFfmpegPath); }); }); }); describe('screencast_stop', () => { it('returns an error message if no recording is active', async () => { await withMcpContext(async (response, context) => { assert.strictEqual(context.getScreenRecorder(), null); await stopScreencast.handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, ); assert.ok( response.responseLines .join('\n') .includes('no active screencast recording to stop'), ); }); }); it('stops an active recording and reports the file path', async () => { await withMcpContext(async (response, context) => { const mockRecorder = createMockRecorder(); const filePath = path.join(os.tmpdir(), 'test-recording.mp4'); context.setScreenRecorder({ recorder: mockRecorder as never, filePath, }); await stopScreencast.handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, ); sinon.assert.calledOnce(mockRecorder.stop); assert.strictEqual(context.getScreenRecorder(), null); assert.ok( response.responseLines .join('\n') .includes(`stopped and saved to ${filePath}`), ); }); }); it('clears the recorder even if stop() throws', async () => { await withMcpContext(async (response, context) => { const mockRecorder = createMockRecorder(); mockRecorder.stop.rejects(new Error('ffmpeg process error')); context.setScreenRecorder({ recorder: mockRecorder as never, filePath: path.join(os.tmpdir(), 'test.mp4'), }); await assert.rejects( stopScreencast.handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, ), /ffmpeg process error/, ); assert.strictEqual(context.getScreenRecorder(), null); }); }); }); });