mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
test: Migrate @n8n/mcp-browser test suite from Jest to Vitest (no-changelog) (#31527)
This commit is contained in:
parent
151fd83e0a
commit
2824370072
|
|
@ -1,5 +0,0 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...require('../../../jest.config'),
|
||||
testTimeout: 30_000,
|
||||
};
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
8
packages/@n8n/mcp-browser/vite.config.ts
Normal file
8
packages/@n8n/mcp-browser/vite.config.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user