diff --git a/packages/@n8n/mcp-browser/jest.config.js b/packages/@n8n/mcp-browser/jest.config.js deleted file mode 100644 index 1126325b266..00000000000 --- a/packages/@n8n/mcp-browser/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - ...require('../../../jest.config'), - testTimeout: 30_000, -}; diff --git a/packages/@n8n/mcp-browser/package.json b/packages/@n8n/mcp-browser/package.json index f74ffac7982..538bc3ed5bc 100644 --- a/packages/@n8n/mcp-browser/package.json +++ b/packages/@n8n/mcp-browser/package.json @@ -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:" } } diff --git a/packages/@n8n/mcp-browser/src/__tests__/cdp-relay.test.ts b/packages/@n8n/mcp-browser/src/__tests__/cdp-relay.test.ts index c445ae79bbb..83d71960068 100644 --- a/packages/@n8n/mcp-browser/src/__tests__/cdp-relay.test.ts +++ b/packages/@n8n/mcp-browser/src/__tests__/cdp-relay.test.ts @@ -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((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(); diff --git a/packages/@n8n/mcp-browser/src/adapters/agent-browser.test.ts b/packages/@n8n/mcp-browser/src/adapters/agent-browser.test.ts index d6b67bb30b6..59122043e1d 100644 --- a/packages/@n8n/mcp-browser/src/adapters/agent-browser.test.ts +++ b/packages/@n8n/mcp-browser/src/adapters/agent-browser.test.ts @@ -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)[util.promisify.custom] = execFileAsyncMock; return { execFile: execFileFn }; diff --git a/packages/@n8n/mcp-browser/src/tools/credential.test.ts b/packages/@n8n/mcp-browser/src/tools/credential.test.ts index 9f6c3cbdcf8..9d1eba3f397 100644 --- a/packages/@n8n/mcp-browser/src/tools/credential.test.ts +++ b/packages/@n8n/mcp-browser/src/tools/credential.test.ts @@ -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 & { _store: Map> } { +function makeBuffer(): Mocked & { _store: Map> } { const store = new Map>(); 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; let buffer: ReturnType; - 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 = () => diff --git a/packages/@n8n/mcp-browser/src/tools/navigation.test.ts b/packages/@n8n/mcp-browser/src/tools/navigation.test.ts index 84c22aa9c3e..5043fdf56f5 100644 --- a/packages/@n8n/mcp-browser/src/tools/navigation.test.ts +++ b/packages/@n8n/mcp-browser/src/tools/navigation.test.ts @@ -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(); }); diff --git a/packages/@n8n/mcp-browser/src/tools/session.test.ts b/packages/@n8n/mcp-browser/src/tools/session.test.ts index 36f84e02464..93f79dd7c1d 100644 --- a/packages/@n8n/mcp-browser/src/tools/session.test.ts +++ b/packages/@n8n/mcp-browser/src/tools/session.test.ts @@ -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); diff --git a/packages/@n8n/mcp-browser/src/tools/test-helpers.ts b/packages/@n8n/mcp-browser/src/tools/test-helpers.ts index 67bba9ab108..a360b2060e0 100644 --- a/packages/@n8n/mcp-browser/src/tools/test-helpers.ts +++ b/packages/@n8n/mcp-browser/src/tools/test-helpers.ts @@ -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: '

Hello world

', 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) => 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; diff --git a/packages/@n8n/mcp-browser/tsconfig.json b/packages/@n8n/mcp-browser/tsconfig.json index 8036ebff7f6..d41b2ba23b0 100644 --- a/packages/@n8n/mcp-browser/tsconfig.json +++ b/packages/@n8n/mcp-browser/tsconfig.json @@ -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"] diff --git a/packages/@n8n/mcp-browser/vite.config.ts b/packages/@n8n/mcp-browser/vite.config.ts new file mode 100644 index 00000000000..d914c9d346c --- /dev/null +++ b/packages/@n8n/mcp-browser/vite.config.ts @@ -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, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e163dce7f14..6f44273b919 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: