test: Migrate @n8n/mcp-browser test suite from Jest to Vitest (no-changelog) (#31527)

This commit is contained in:
Matsu 2026-06-03 08:57:20 +03:00 committed by GitHub
parent 151fd83e0a
commit 2824370072
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 106 additions and 86 deletions

View File

@ -1,5 +0,0 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
testTimeout: 30_000,
};

View File

@ -18,9 +18,9 @@
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false"
},
"dependencies": {
"@joplin/turndown-plugin-gfm": "1.0.64",
@ -39,8 +39,11 @@
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.18.1",
"@types/yargs-parser": "21.0.0"
"@types/yargs-parser": "21.0.0",
"@vitest/coverage-v8": "catalog:",
"vitest": "catalog:"
}
}

View File

@ -107,18 +107,18 @@ describe('CDPRelayServer', () => {
});
it('should reject waitForExtension after timeout', async () => {
jest.useFakeTimers();
vi.useFakeTimers();
relay.stop();
relay = new CDPRelayServer({ connectionTimeoutMs: 2_000 });
port = await relay.listen();
// Capture the promise before advancing timers
const promise = relay.waitForExtension().catch((e: unknown) => e);
await jest.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(2_100);
const error = await promise;
expect(error).toBeInstanceOf(ExtensionNotConnectedError);
jest.useRealTimers();
vi.useRealTimers();
});
it('should report disconnect reason when extension closes with explicit reason', async () => {
@ -176,7 +176,7 @@ describe('CDPRelayServer', () => {
it('should disconnect extension after heartbeat timeout', async () => {
// Enable fake timers before creating relay so setInterval is captured
jest.useFakeTimers();
vi.useFakeTimers();
relay.stop();
relay = new CDPRelayServer({ connectionTimeoutMs: 2_000 });
@ -185,7 +185,7 @@ describe('CDPRelayServer', () => {
new WebSocket(relay.extensionEndpoint(port), { autoPong: false });
// Let WebSocket handshake complete through the event loop
await jest.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(100);
await relay.waitForExtension();
const disconnectPromise = new Promise<string>((resolve) => {
@ -196,17 +196,17 @@ describe('CDPRelayServer', () => {
// With the sleep-aware heartbeat the first termination check that passes
// requires a ping to have been sent AFTER the last pong, which adds one
// extra 5s interval compared to the naive elapsed-only check.
await jest.advanceTimersByTimeAsync(25_000);
await vi.advanceTimersByTimeAsync(25_000);
const reason = await disconnectPromise;
expect(reason).toBe('heartbeat_timeout');
// Restore real timers before afterEach cleanup (ws.close uses setTimeout)
jest.useRealTimers();
vi.useRealTimers();
});
it('should allow extension to connect after waitForExtension times out', async () => {
jest.useFakeTimers();
vi.useFakeTimers();
relay.stop();
relay = new CDPRelayServer({ connectionTimeoutMs: 2_000 });
@ -214,11 +214,11 @@ describe('CDPRelayServer', () => {
// Timeout the waitForExtension call — relay stays alive
const waitPromise = relay.waitForExtension().catch((e: unknown) => e);
await jest.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(2_100);
const error = await waitPromise;
expect(error).toBeInstanceOf(ExtensionNotConnectedError);
jest.useRealTimers();
vi.useRealTimers();
// Extension connects after the timeout — must be registered
const ext = connectExtension();

View File

@ -9,23 +9,22 @@ configureLogger({ level: 'silent' });
// Mock node:child_process
//
// The adapter runs `const execFileAsync = promisify(execFile)` at module load
// time. To intercept those calls we attach our jest mock as the
// time. To intercept those calls we attach our mock as the
// util.promisify.custom symbol on the mocked execFile, so promisify() returns
// our function directly.
//
// `execFileAsyncMock` must be `var` (not `let`/`const`) so its declaration is
// hoisted before jest.mock is processed. The jest.mock factory — which runs
// when agent-browser.ts is first imported — then assigns the concrete mock to
// the hoisted (undefined) variable, making it available in all tests.
// `execFileAsyncMock` is created inside `vi.hoisted` so it exists before the
// hoisted `vi.mock` factory runs (a factory may only reference imports and
// `vi.hoisted` values). The same mock is then exposed to the tests below.
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-var
var execFileAsyncMock: jest.Mock;
const { execFileAsyncMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
}));
jest.mock('node:child_process', () => {
vi.mock('node:child_process', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const util = require('node:util') as { promisify: { custom: symbol } };
const execFileFn = jest.fn();
execFileAsyncMock = jest.fn().mockResolvedValue({ stdout: '', stderr: '' });
const execFileFn = vi.fn();
// Attach the custom promisify so promisify(execFile) === execFileAsyncMock
(execFileFn as unknown as Record<symbol, unknown>)[util.promisify.custom] = execFileAsyncMock;
return { execFile: execFileFn };

View File

@ -1,3 +1,5 @@
import type { Mocked, MockedFunction } from 'vitest';
import type { SecretsBuffer, ToolContext } from '../types';
import { createCredentialTools } from './credential';
import { createMockConnection, findTool, structuredOf } from './test-helpers';
@ -6,16 +8,16 @@ import { createMockConnection, findTool, structuredOf } from './test-helpers';
// Helpers
// ---------------------------------------------------------------------------
function makeBuffer(): jest.Mocked<SecretsBuffer> & { _store: Map<string, Map<string, string>> } {
function makeBuffer(): Mocked<SecretsBuffer> & { _store: Map<string, Map<string, string>> } {
const store = new Map<string, Map<string, string>>();
return {
_store: store,
capture: jest.fn((key: string, field: string, value: string) => {
capture: vi.fn((key: string, field: string, value: string) => {
if (!store.has(key)) store.set(key, new Map());
store.get(key)!.set(field, value);
}),
getFields: jest.fn((key: string) => store.get(key)),
clear: jest.fn((key: string) => {
getFields: vi.fn((key: string) => store.get(key)),
clear: vi.fn((key: string) => {
store.delete(key);
}),
};
@ -267,7 +269,7 @@ describe('browser_capture_secret', () => {
describe('browser_create_credential', () => {
let mockConn: ReturnType<typeof createMockConnection>;
let buffer: ReturnType<typeof makeBuffer>;
let createCredential: jest.MockedFunction<
let createCredential: MockedFunction<
(p: {
name: string;
type: string;
@ -282,7 +284,7 @@ describe('browser_create_credential', () => {
// Pre-populate buffer with some captured secrets
buffer.capture('k1', 'clientId', 'client-id-value');
buffer.capture('k1', 'clientSecret', 'client-secret-value');
createCredential = jest.fn().mockResolvedValue({ credentialId: 'cred-123' });
createCredential = vi.fn().mockResolvedValue({ credentialId: 'cred-123' });
});
const getTool = () =>

View File

@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { Mock } from 'vitest';
import { NotConnectedError } from '../errors';
import { createNavigationTools } from './navigation';
import { createMockConnection, findTool, structuredOf, textOf, TOOL_CONTEXT } from './test-helpers';
@ -93,7 +95,7 @@ describe('createNavigationTools', () => {
});
it('returns error when not connected', async () => {
(mockConnection.connection.getConnection as jest.Mock).mockImplementation(() => {
(mockConnection.connection.getConnection as Mock).mockImplementation(() => {
throw new NotConnectedError();
});

View File

@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { Mock } from 'vitest';
import { McpBrowserError } from '../errors';
import { createSessionTools } from './session';
import { createMockConnection, findTool, structuredOf, TOOL_CONTEXT } from './test-helpers';
@ -86,7 +88,7 @@ describe('createSessionTools', () => {
});
it('returns error when McpBrowserError is thrown', async () => {
(freshConnection.connection.connect as jest.Mock).mockRejectedValue(
(freshConnection.connection.connect as Mock).mockRejectedValue(
new McpBrowserError('Already connected', 'Call browser_disconnect first'),
);
const freshTools = createSessionTools(freshConnection.connection);
@ -101,7 +103,7 @@ describe('createSessionTools', () => {
});
it('wraps generic errors in McpBrowserError', async () => {
(freshConnection.connection.connect as jest.Mock).mockRejectedValue(
(freshConnection.connection.connect as Mock).mockRejectedValue(
new Error('Connection refused'),
);
const freshTools = createSessionTools(freshConnection.connection);
@ -157,7 +159,7 @@ describe('createSessionTools', () => {
it('returns error when disconnect throws', async () => {
const fresh = createMockConnection();
(fresh.connection.disconnect as jest.Mock).mockRejectedValue(
(fresh.connection.disconnect as Mock).mockRejectedValue(
new McpBrowserError('Disconnect failed'),
);
const freshTools = createSessionTools(fresh.connection);

View File

@ -35,44 +35,44 @@ export const TOOL_CONTEXT: ToolContext = { dir: '/test' };
export function createMockAdapter() {
return {
// Session
launch: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
launch: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
// Tab management
newPage: jest.fn().mockResolvedValue({ id: 'page2', title: 'New Page', url: 'about:blank' }),
closePage: jest.fn().mockResolvedValue(undefined),
focusPage: jest.fn().mockResolvedValue(undefined),
listPages: jest
newPage: vi.fn().mockResolvedValue({ id: 'page2', title: 'New Page', url: 'about:blank' }),
closePage: vi.fn().mockResolvedValue(undefined),
focusPage: vi.fn().mockResolvedValue(undefined),
listPages: vi
.fn()
.mockResolvedValue([{ id: 'page1', title: 'Test Page', url: 'http://test.com' }]),
listTabs: jest
listTabs: vi
.fn()
.mockResolvedValue([{ id: 'page1', title: 'Test Page', url: 'http://test.com' }]),
listTabSessionIds: jest.fn().mockReturnValue(['page1']),
listTabIds: jest.fn().mockResolvedValue(['page1']),
listTabSessionIds: vi.fn().mockReturnValue(['page1']),
listTabIds: vi.fn().mockResolvedValue(['page1']),
// Navigation
navigate: jest
navigate: vi
.fn()
.mockResolvedValue({ title: 'Test Page', url: 'http://test.com', status: 200 }),
back: jest.fn().mockResolvedValue({ title: 'Previous', url: 'http://test.com/prev' }),
forward: jest.fn().mockResolvedValue({ title: 'Next', url: 'http://test.com/next' }),
reload: jest.fn().mockResolvedValue({ title: 'Reloaded', url: 'http://test.com' }),
back: vi.fn().mockResolvedValue({ title: 'Previous', url: 'http://test.com/prev' }),
forward: vi.fn().mockResolvedValue({ title: 'Next', url: 'http://test.com/next' }),
reload: vi.fn().mockResolvedValue({ title: 'Reloaded', url: 'http://test.com' }),
// Interaction
click: jest.fn().mockResolvedValue(undefined),
type: jest.fn().mockResolvedValue(undefined),
select: jest.fn().mockResolvedValue(['option1']),
hover: jest.fn().mockResolvedValue(undefined),
press: jest.fn().mockResolvedValue(undefined),
drag: jest.fn().mockResolvedValue(undefined),
scroll: jest.fn().mockResolvedValue(undefined),
upload: jest.fn().mockResolvedValue(undefined),
dialog: jest.fn().mockResolvedValue('alert'),
click: vi.fn().mockResolvedValue(undefined),
type: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockResolvedValue(['option1']),
hover: vi.fn().mockResolvedValue(undefined),
press: vi.fn().mockResolvedValue(undefined),
drag: vi.fn().mockResolvedValue(undefined),
scroll: vi.fn().mockResolvedValue(undefined),
upload: vi.fn().mockResolvedValue(undefined),
dialog: vi.fn().mockResolvedValue('alert'),
// Inspection
snapshot: jest.fn().mockResolvedValue({ tree: '', refCount: 0 }),
probePageHtml: jest.fn().mockResolvedValue({
snapshot: vi.fn().mockResolvedValue({ tree: '', refCount: 0 }),
probePageHtml: vi.fn().mockResolvedValue({
ok: true,
root: {
kind: 'document',
@ -82,38 +82,38 @@ export function createMockAdapter() {
errors: [],
},
}),
screenshot: jest.fn().mockResolvedValue('base64imagedata'),
getContent: jest.fn().mockResolvedValue({
screenshot: vi.fn().mockResolvedValue('base64imagedata'),
getContent: vi.fn().mockResolvedValue({
html: '<html><body><p>Hello world</p></body></html>',
url: 'http://test.com',
}),
evaluate: jest.fn().mockResolvedValue(42),
getConsole: jest.fn().mockResolvedValue([]),
pdf: jest.fn().mockResolvedValue({ data: 'base64pdf', pages: 1 }),
getNetwork: jest.fn().mockResolvedValue([]),
getText: jest.fn().mockResolvedValue('Hello'),
evaluate: vi.fn().mockResolvedValue(42),
getConsole: vi.fn().mockResolvedValue([]),
pdf: vi.fn().mockResolvedValue({ data: 'base64pdf', pages: 1 }),
getNetwork: vi.fn().mockResolvedValue([]),
getText: vi.fn().mockResolvedValue('Hello'),
// Wait
wait: jest.fn().mockResolvedValue(100),
wait: vi.fn().mockResolvedValue(100),
// State
getCookies: jest.fn().mockResolvedValue([]),
setCookies: jest.fn().mockResolvedValue(undefined),
clearCookies: jest.fn().mockResolvedValue(undefined),
getStorage: jest.fn().mockResolvedValue({}),
setStorage: jest.fn().mockResolvedValue(undefined),
clearStorage: jest.fn().mockResolvedValue(undefined),
getCookies: vi.fn().mockResolvedValue([]),
setCookies: vi.fn().mockResolvedValue(undefined),
clearCookies: vi.fn().mockResolvedValue(undefined),
getStorage: vi.fn().mockResolvedValue({}),
setStorage: vi.fn().mockResolvedValue(undefined),
clearStorage: vi.fn().mockResolvedValue(undefined),
// Credential helpers
getElementValue: jest.fn().mockResolvedValue(''),
getElementValue: vi.fn().mockResolvedValue(''),
// URL lookup
getPageUrl: jest.fn().mockReturnValue('http://test.com'),
getPageUrl: vi.fn().mockReturnValue('http://test.com'),
// Enrichment
getModalStates: jest.fn().mockReturnValue([]),
getConsoleSummary: jest.fn().mockReturnValue({ errors: 0, warnings: 0 }),
waitForCompletion: jest
getModalStates: vi.fn().mockReturnValue([]),
getConsoleSummary: vi.fn().mockReturnValue({ errors: 0, warnings: 0 }),
waitForCompletion: vi
.fn()
.mockImplementation(async (_pageId: string, fn: () => Promise<unknown>) => await fn()),
};
@ -135,12 +135,12 @@ export function createMockConnection(adapter?: MockAdapter) {
};
const connection = {
getConnection: jest.fn().mockReturnValue(state),
connect: jest.fn().mockResolvedValue({
getConnection: vi.fn().mockReturnValue(state),
connect: vi.fn().mockResolvedValue({
browser: 'chrome',
pages: [{ id: 'page1', title: 'Test Page', url: 'http://test.com' }],
}),
disconnect: jest.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: true,
} as unknown as BrowserConnection;

View File

@ -1,7 +1,7 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"types": ["node", "jest"],
"types": ["node", "vitest/globals"],
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts"]

View File

@ -0,0 +1,8 @@
import { createVitestConfig } from '@n8n/vitest-config/node';
export default createVitestConfig({
// The n8n root jest.config sets `restoreMocks: true`, and test files silently rely on
// it — omit this and mocks bleed between tests.
restoreMocks: true,
testTimeout: 30_000,
});

View File

@ -2169,6 +2169,9 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../vitest-config
'@types/turndown':
specifier: ^5.0.6
version: 5.0.6
@ -2178,6 +2181,12 @@ importers:
'@types/yargs-parser':
specifier: 21.0.0
version: 21.0.0
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
vitest:
specifier: 'catalog:'
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
packages/@n8n/mcp-browser-extension:
dependencies: