From 5feafdfafdd47bcf67c49288d3d4f96c08e01daa Mon Sep 17 00:00:00 2001 From: Matsu Date: Wed, 3 Jun 2026 16:42:19 +0300 Subject: [PATCH] test: Migrate @n8n/computer-use from Jest to Vitest (#31482) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/@n8n/computer-use/jest.config.js | 12 --- packages/@n8n/computer-use/package.json | 11 ++- .../src/__mocks__/@inquirer/prompts.ts | 6 +- .../computer-use/src/gateway-client.test.ts | 99 ++++++++++--------- .../computer-use/src/gateway-session.test.ts | 14 +-- packages/@n8n/computer-use/src/logger.test.ts | 8 +- .../computer-use/src/settings-store.test.ts | 17 ++-- .../src/startup-config-cli.test.ts | 11 ++- .../src/tools/filesystem/copy-file.test.ts | 11 ++- .../tools/filesystem/create-directory.test.ts | 11 ++- .../src/tools/filesystem/delete.test.ts | 19 ++-- .../src/tools/filesystem/edit-file.test.ts | 13 +-- .../src/tools/filesystem/fs-utils.test.ts | 25 ++--- .../tools/filesystem/get-file-tree.test.ts | 11 ++- .../src/tools/filesystem/list-files.test.ts | 13 +-- .../src/tools/filesystem/move.test.ts | 11 ++- .../src/tools/filesystem/read-file.test.ts | 11 ++- .../src/tools/filesystem/write-file.test.ts | 11 ++- .../mouse-keyboard/mouse-keyboard.test.ts | 70 ++++++------- .../src/tools/screenshot/screenshot.test.ts | 92 ++++++++--------- .../src/tools/shell/shell-execute.test.ts | 37 ++++--- packages/@n8n/computer-use/tsconfig.json | 2 +- packages/@n8n/computer-use/vite.config.ts | 23 +++++ pnpm-lock.yaml | 9 ++ 24 files changed, 293 insertions(+), 254 deletions(-) delete mode 100644 packages/@n8n/computer-use/jest.config.js create mode 100644 packages/@n8n/computer-use/vite.config.ts diff --git a/packages/@n8n/computer-use/jest.config.js b/packages/@n8n/computer-use/jest.config.js deleted file mode 100644 index 64884f95edb..00000000000 --- a/packages/@n8n/computer-use/jest.config.js +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('jest').Config} */ -const base = require('../../../jest.config'); - -module.exports = { - ...base, - moduleNameMapper: { - ...base.moduleNameMapper, - // @inquirer/prompts and all its sub-packages are ESM-only. - // Tests that don't need interactive prompts can use this mock. - '^@inquirer/(.*)$': '/src/__mocks__/@inquirer/prompts.ts', - }, -}; diff --git a/packages/@n8n/computer-use/package.json b/packages/@n8n/computer-use/package.json index fc032d8fc78..13733d63f2c 100644 --- a/packages/@n8n/computer-use/package.json +++ b/packages/@n8n/computer-use/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" }, "main": "dist/cli.js", "exports": { @@ -54,7 +54,10 @@ }, "devDependencies": { "@n8n/typescript-config": "workspace:*", + "@n8n/vitest-config": "workspace:*", "@types/node": "catalog:", - "@types/yargs-parser": "21.0.0" + "@types/yargs-parser": "21.0.0", + "@vitest/coverage-v8": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/@n8n/computer-use/src/__mocks__/@inquirer/prompts.ts b/packages/@n8n/computer-use/src/__mocks__/@inquirer/prompts.ts index 5a4f9663261..610ff340686 100644 --- a/packages/@n8n/computer-use/src/__mocks__/@inquirer/prompts.ts +++ b/packages/@n8n/computer-use/src/__mocks__/@inquirer/prompts.ts @@ -1,3 +1,3 @@ -export const select = jest.fn(); -export const confirm = jest.fn(); -export const input = jest.fn(); +export const select = vi.fn(); +export const confirm = vi.fn(); +export const input = vi.fn(); diff --git a/packages/@n8n/computer-use/src/gateway-client.test.ts b/packages/@n8n/computer-use/src/gateway-client.test.ts index 1771db406c7..2763c5db64c 100644 --- a/packages/@n8n/computer-use/src/gateway-client.test.ts +++ b/packages/@n8n/computer-use/src/gateway-client.test.ts @@ -1,3 +1,4 @@ +import type { Mock, Mocked } from 'vitest'; /** * Unit tests for GatewayClient.checkPermissions (tested indirectly via dispatchToolCall). * @@ -11,33 +12,33 @@ // --------------------------------------------------------------------------- // Suppress logger noise during tests -jest.mock('./logger', () => ({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - printAuthFailure: jest.fn(), - printDisconnected: jest.fn(), - printReconnecting: jest.fn(), - printReinitFailed: jest.fn(), - printReinitializing: jest.fn(), - printToolCall: jest.fn(), - printToolResult: jest.fn(), +vi.mock('./logger', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() }, + printAuthFailure: vi.fn(), + printDisconnected: vi.fn(), + printReconnecting: vi.fn(), + printReinitFailed: vi.fn(), + printReinitializing: vi.fn(), + printToolCall: vi.fn(), + printToolResult: vi.fn(), })); // Mock tool modules that pull in native/ESM-only dependencies -jest.mock('./tools/shell', () => ({ - ['ShellModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] }, +vi.mock('./tools/shell', () => ({ + ['ShellModule']: { isSupported: vi.fn().mockResolvedValue(false), definitions: [] }, })); -jest.mock('./tools/filesystem', () => ({ +vi.mock('./tools/filesystem', () => ({ filesystemReadTools: [], filesystemWriteTools: [], })); -jest.mock('./tools/screenshot', () => ({ - ['ScreenshotModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] }, +vi.mock('./tools/screenshot', () => ({ + ['ScreenshotModule']: { isSupported: vi.fn().mockResolvedValue(false), definitions: [] }, })); -jest.mock('./tools/mouse-keyboard', () => ({ - ['MouseKeyboardModule']: { isSupported: jest.fn().mockResolvedValue(false), definitions: [] }, +vi.mock('./tools/mouse-keyboard', () => ({ + ['MouseKeyboardModule']: { isSupported: vi.fn().mockResolvedValue(false), definitions: [] }, })); -jest.mock('./tools/browser', () => ({ - ['BrowserModule']: { create: jest.fn().mockResolvedValue(null) }, +vi.mock('./tools/browser', () => ({ + ['BrowserModule']: { create: vi.fn().mockResolvedValue(null) }, })); import type { GatewayConfig } from './config'; @@ -62,27 +63,27 @@ function makeConfig(permissionConfirmation: 'client' | 'instance' = 'client'): G }; } -function makeSession(overrides: Partial = {}): jest.Mocked { +function makeSession(overrides: Partial = {}): Mocked { return { dir: '/tmp', - check: jest.fn().mockReturnValue('ask'), - getAllPermissions: jest.fn().mockReturnValue({ + check: vi.fn().mockReturnValue('ask'), + getAllPermissions: vi.fn().mockReturnValue({ filesystemRead: 'allow', filesystemWrite: 'ask', shell: 'ask', computer: 'deny', browser: 'ask', }), - setPermissions: jest.fn(), - setDir: jest.fn(), - getGroupMode: jest.fn().mockReturnValue('allow'), - allowForSession: jest.fn(), - clearSession: jest.fn(), - alwaysAllow: jest.fn(), - alwaysDeny: jest.fn(), - flush: jest.fn().mockResolvedValue(undefined), + setPermissions: vi.fn(), + setDir: vi.fn(), + getGroupMode: vi.fn().mockReturnValue('allow'), + allowForSession: vi.fn(), + clearSession: vi.fn(), + alwaysAllow: vi.fn(), + alwaysDeny: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), ...overrides, - } as unknown as jest.Mocked; + } as unknown as Mocked; } const SHELL_RESOURCE: AffectedResource = { @@ -98,8 +99,8 @@ function makeTool(resources: AffectedResource[]): ToolDefinition { description: 'Test tool', inputSchema: { parse: (x: unknown) => x } as ToolDefinition['inputSchema'], annotations: {}, - execute: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }), - getAffectedResources: jest.fn().mockResolvedValue(resources), + execute: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }), + getAffectedResources: vi.fn().mockResolvedValue(resources), }; } @@ -108,7 +109,7 @@ function makeTool(resources: AffectedResource[]): ToolDefinition { * normal async initialisation (uploadCapabilities / getAllDefinitions). */ function makeClient( - session: jest.Mocked, + session: Mocked, confirmResourceAccess: ConfirmResourceAccess, permissionConfirmation: 'client' | 'instance' = 'client', resources: AffectedResource[] = [SHELL_RESOURCE], @@ -140,7 +141,7 @@ describe('GatewayClient.checkPermissions', () => { describe('client mode', () => { it('allowOnce — does not modify session permissions', async () => { const session = makeSession(); - const confirmResourceAccess = jest.fn().mockResolvedValue('allowOnce'); + const confirmResourceAccess = vi.fn().mockResolvedValue('allowOnce'); const client = makeClient(session, confirmResourceAccess); await client['dispatchToolCall']('test_tool', {}); @@ -152,7 +153,7 @@ describe('GatewayClient.checkPermissions', () => { it('allowForSession — allows the specific resource for the session', async () => { const session = makeSession(); - const confirmResourceAccess = jest.fn().mockResolvedValue('allowForSession'); + const confirmResourceAccess = vi.fn().mockResolvedValue('allowForSession'); const client = makeClient(session, confirmResourceAccess); await client['dispatchToolCall']('test_tool', {}); @@ -163,7 +164,7 @@ describe('GatewayClient.checkPermissions', () => { it('alwaysAllow — delegates to session.alwaysAllow', async () => { const session = makeSession(); - const confirmResourceAccess = jest.fn().mockResolvedValue('alwaysAllow'); + const confirmResourceAccess = vi.fn().mockResolvedValue('alwaysAllow'); const client = makeClient(session, confirmResourceAccess); await client['dispatchToolCall']('test_tool', {}); @@ -173,7 +174,7 @@ describe('GatewayClient.checkPermissions', () => { it('denyOnce — throws without persisting', async () => { const session = makeSession(); - const confirmResourceAccess = jest.fn().mockResolvedValue('denyOnce'); + const confirmResourceAccess = vi.fn().mockResolvedValue('denyOnce'); const client = makeClient(session, confirmResourceAccess); await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow( @@ -185,7 +186,7 @@ describe('GatewayClient.checkPermissions', () => { it('alwaysDeny — persists and throws', async () => { const session = makeSession(); - const confirmResourceAccess = jest.fn().mockResolvedValue('alwaysDeny'); + const confirmResourceAccess = vi.fn().mockResolvedValue('alwaysDeny'); const client = makeClient(session, confirmResourceAccess); await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow( @@ -195,8 +196,8 @@ describe('GatewayClient.checkPermissions', () => { }); it('skips confirmation when session.check returns allow', async () => { - const session = makeSession({ check: jest.fn().mockReturnValue('allow') }); - const confirmResourceAccess = jest.fn(); + const session = makeSession({ check: vi.fn().mockReturnValue('allow') }); + const confirmResourceAccess = vi.fn(); const client = makeClient(session, confirmResourceAccess); await client['dispatchToolCall']('test_tool', {}); @@ -205,8 +206,8 @@ describe('GatewayClient.checkPermissions', () => { }); it('throws immediately when session.check returns deny', async () => { - const session = makeSession({ check: jest.fn().mockReturnValue('deny') }); - const confirmResourceAccess = jest.fn(); + const session = makeSession({ check: vi.fn().mockReturnValue('deny') }); + const confirmResourceAccess = vi.fn(); const client = makeClient(session, confirmResourceAccess); await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow( @@ -219,7 +220,7 @@ describe('GatewayClient.checkPermissions', () => { describe('instance mode', () => { it('throws GATEWAY_CONFIRMATION_REQUIRED with the 3-option list', async () => { const session = makeSession(); - const confirmResourceAccess = jest.fn(); + const confirmResourceAccess = vi.fn(); const client = makeClient(session, confirmResourceAccess, 'instance'); await expect(client['dispatchToolCall']('test_tool', {})).rejects.toThrow( @@ -246,7 +247,7 @@ describe('GatewayClient.checkPermissions', () => { it('applies _confirmation decision in instance mode without prompting', async () => { const session = makeSession(); - const confirmResourceAccess = jest.fn(); + const confirmResourceAccess = vi.fn(); const client = makeClient(session, confirmResourceAccess, 'instance'); // Simulate the agent sending back _confirmation=allowForSession @@ -262,7 +263,7 @@ describe('GatewayClient.uploadCapabilities', () => { const originalFetch = global.fetch; beforeEach(() => { - global.fetch = jest.fn(); + global.fetch = vi.fn(); }); afterEach(() => { @@ -275,7 +276,7 @@ describe('GatewayClient.uploadCapabilities', () => { apiKey: 'tok', config: makeConfig(), session: makeSession(), - confirmResourceAccess: jest.fn(), + confirmResourceAccess: vi.fn(), }); // Bypass tool discovery — uploadCapabilities only needs definitions to exist. @@ -288,11 +289,11 @@ describe('GatewayClient.uploadCapabilities', () => { } function mockFetchResponse(status: number, body = ''): void { - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: status >= 200 && status < 300, status, - text: jest.fn().mockResolvedValue(body), - json: jest.fn().mockResolvedValue({ data: { ok: true } }), + text: vi.fn().mockResolvedValue(body), + json: vi.fn().mockResolvedValue({ data: { ok: true } }), }); } diff --git a/packages/@n8n/computer-use/src/gateway-session.test.ts b/packages/@n8n/computer-use/src/gateway-session.test.ts index 281632c0d37..149dd867480 100644 --- a/packages/@n8n/computer-use/src/gateway-session.test.ts +++ b/packages/@n8n/computer-use/src/gateway-session.test.ts @@ -1,3 +1,5 @@ +import type { Mocked } from 'vitest'; + import * as config from './config'; import type { ToolGroup } from './config'; import { GatewaySession, buildDefaultPermissions } from './gateway-session'; @@ -12,17 +14,15 @@ function makeStore( allow: Record; deny: Record; }> = {}, -): jest.Mocked< - Pick -> { +): Mocked> { return { - getResourcePermissions: jest.fn((toolGroup: ToolGroup) => ({ + getResourcePermissions: vi.fn((toolGroup: ToolGroup) => ({ allow: overrides.allow?.[toolGroup] ?? [], deny: overrides.deny?.[toolGroup] ?? [], })), - alwaysAllow: jest.fn(), - alwaysDeny: jest.fn(), - flush: jest.fn().mockResolvedValue(undefined), + alwaysAllow: vi.fn(), + alwaysDeny: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), }; } diff --git a/packages/@n8n/computer-use/src/logger.test.ts b/packages/@n8n/computer-use/src/logger.test.ts index 782eb5a2c7e..74e5bc06bd8 100644 --- a/packages/@n8n/computer-use/src/logger.test.ts +++ b/packages/@n8n/computer-use/src/logger.test.ts @@ -1,3 +1,5 @@ +import type { MockInstance } from 'vitest'; + import type { GatewayConfig } from './config'; import { logger, printModuleStatus } from './logger'; @@ -14,7 +16,7 @@ const BASE_CONFIG: GatewayConfig = { }; /** Find the message logged for a specific module by inspecting the meta argument. */ -function messageFor(spy: jest.SpyInstance, module: string): string { +function messageFor(spy: MockInstance, module: string): string { const call: [string, Record] | undefined = ( spy.mock.calls as Array<[string, Record]> ).find(([, meta]) => meta?.module === module); @@ -22,10 +24,10 @@ function messageFor(spy: jest.SpyInstance, module: string): string { } describe('printModuleStatus', () => { - let infoSpy: jest.SpyInstance; + let infoSpy: MockInstance; beforeEach(() => { - infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {}); + infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); }); afterEach(() => { diff --git a/packages/@n8n/computer-use/src/settings-store.test.ts b/packages/@n8n/computer-use/src/settings-store.test.ts index 8198e37d510..33199762181 100644 --- a/packages/@n8n/computer-use/src/settings-store.test.ts +++ b/packages/@n8n/computer-use/src/settings-store.test.ts @@ -1,10 +1,11 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; +import type { Mock } from 'vitest'; -jest.mock('node:os', () => { - const actual = jest.requireActual('node:os'); - return { ...actual, homedir: jest.fn(() => actual.homedir()) }; +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { ...actual, homedir: vi.fn(() => actual.homedir()) }; }); import type { GatewayConfig } from './config'; @@ -36,7 +37,7 @@ async function createStore( tmpDir: string, initial?: Record, ): Promise { - (os.homedir as jest.Mock).mockReturnValue(tmpDir); + (os.homedir as Mock).mockReturnValue(tmpDir); if (initial !== undefined) { const dir = path.join(tmpDir, '.n8n-gateway'); await fs.mkdir(dir, { recursive: true }); @@ -56,7 +57,7 @@ beforeEach(async () => { }); afterEach(async () => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -80,7 +81,7 @@ describe('SettingsStore.create', () => { }); it('tolerates a malformed file and starts with empty state', async () => { - (os.homedir as jest.Mock).mockReturnValue(tmpDir); + (os.homedir as Mock).mockReturnValue(tmpDir); const filePath = path.join(tmpDir, '.n8n-gateway', 'settings.json'); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, 'not-json', 'utf-8'); @@ -95,7 +96,7 @@ describe('SettingsStore.create', () => { describe('SettingsStore.ensureInitialized', () => { it('creates the settings file when absent', async () => { - (os.homedir as jest.Mock).mockReturnValue(tmpDir); + (os.homedir as Mock).mockReturnValue(tmpDir); await SettingsStore.ensureInitialized(BASE_CONFIG); const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8'); @@ -112,7 +113,7 @@ describe('SettingsStore.ensureInitialized', () => { }); it('does not overwrite an existing settings file', async () => { - (os.homedir as jest.Mock).mockReturnValue(tmpDir); + (os.homedir as Mock).mockReturnValue(tmpDir); const dir = path.join(tmpDir, '.n8n-gateway'); const file = path.join(dir, 'settings.json'); await fs.mkdir(dir, { recursive: true }); diff --git a/packages/@n8n/computer-use/src/startup-config-cli.test.ts b/packages/@n8n/computer-use/src/startup-config-cli.test.ts index e15c13f563a..ba1455f66eb 100644 --- a/packages/@n8n/computer-use/src/startup-config-cli.test.ts +++ b/packages/@n8n/computer-use/src/startup-config-cli.test.ts @@ -1,10 +1,11 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as nodePath from 'node:path'; +import type { Mock } from 'vitest'; -jest.mock('node:os', () => { - const actual = jest.requireActual('node:os'); - return { ...actual, homedir: jest.fn(() => actual.homedir()) }; +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { ...actual, homedir: vi.fn(() => actual.homedir()) }; }); import type { GatewayConfig } from './config'; @@ -87,11 +88,11 @@ describe('SettingsStore.ensureInitialized', () => { beforeEach(async () => { tmpDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'gateway-test-')); // Point getSettingsFilePath() at our temp location by overriding homedir - (os.homedir as jest.Mock).mockReturnValue(tmpDir); + (os.homedir as Mock).mockReturnValue(tmpDir); }); afterEach(async () => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); await fs.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/copy-file.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/copy-file.test.ts index 466e5cc7a60..8dc8749a43d 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/copy-file.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/copy-file.test.ts @@ -1,24 +1,25 @@ import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { copyFileTool } from './copy-file'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; function mockMkdir(): void { - (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.mkdir as Mock).mockResolvedValue(undefined); } function mockCopyFile(): void { - jest.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.copyFile).mockResolvedValue(undefined); } describe('copyFileTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/create-directory.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/create-directory.test.ts index 24430979ebe..823db43dd28 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/create-directory.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/create-directory.test.ts @@ -1,20 +1,21 @@ import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { createDirectoryTool } from './create-directory'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; function mockMkdir(): void { - (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.mkdir as Mock).mockResolvedValue(undefined); } describe('createDirectoryTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); @@ -60,7 +61,7 @@ describe('createDirectoryTool', () => { it('is idempotent when the directory already exists', async () => { // fs.mkdir with { recursive: true } resolves without error when the dir already exists - (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.mkdir as Mock).mockResolvedValue(undefined); const result = await createDirectoryTool.execute({ dirPath: 'existing' }, CONTEXT); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/delete.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/delete.test.ts index 798bc23c696..07980b92f8d 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/delete.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/delete.test.ts @@ -1,30 +1,31 @@ import type { Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { deleteTool } from './delete'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; function mockStatFile(): void { - jest.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false } as unknown as Stats); + vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false } as unknown as Stats); } function mockStatDirectory(): void { - jest.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true } as unknown as Stats); + vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true } as unknown as Stats); } function mockStatNotFound(): void { const error = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); - jest.mocked(fs.stat).mockRejectedValue(error); + vi.mocked(fs.stat).mockRejectedValue(error); } describe('deleteTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); @@ -57,7 +58,7 @@ describe('deleteTool', () => { describe('execute', () => { it('deletes a file using unlink', async () => { mockStatFile(); - jest.mocked(fs.unlink).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); const result = await deleteTool.execute({ path: 'src/old.ts' }, CONTEXT); @@ -70,7 +71,7 @@ describe('deleteTool', () => { it('deletes a directory recursively using rm', async () => { mockStatDirectory(); - (fs.rm as jest.Mock).mockResolvedValue(undefined); + (fs.rm as Mock).mockResolvedValue(undefined); const result = await deleteTool.execute({ path: 'old-dir' }, CONTEXT); @@ -83,7 +84,7 @@ describe('deleteTool', () => { it('returns a single text content block', async () => { mockStatFile(); - jest.mocked(fs.unlink).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); const result = await deleteTool.execute({ path: 'file.ts' }, CONTEXT); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/edit-file.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/edit-file.test.ts index 0ed3aa6bf79..e93f95318a1 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/edit-file.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/edit-file.test.ts @@ -1,29 +1,30 @@ import type { Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { editFileTool } from './edit-file'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; function mockStat(size: number): void { - jest.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); + vi.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); } function mockReadFile(content: string): void { - (fs.readFile as jest.Mock).mockResolvedValue(content); + (fs.readFile as Mock).mockResolvedValue(content); } function mockWriteFile(): void { - (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + (fs.writeFile as Mock).mockResolvedValue(undefined); } describe('editFileTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/fs-utils.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/fs-utils.test.ts index 22ddcce6943..bcc6fec667d 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/fs-utils.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/fs-utils.test.ts @@ -1,5 +1,6 @@ import type { Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { buildFilesystemResource, @@ -9,14 +10,14 @@ import { } from './fs-utils'; import * as config from '../../config'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const BASE = '/base'; const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); function mockRealpath(entries: Array<[string, string]>): void { const map = new Map(entries); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (map.has(p)) return await Promise.resolve(map.get(p)!); throw enoent(); }); @@ -24,7 +25,7 @@ function mockRealpath(entries: Array<[string, string]>): void { function mockLstat(entries: Array<[string, Partial]>): void { const map = new Map(entries); - jest.mocked(fs.lstat).mockImplementation(async (p) => { + vi.mocked(fs.lstat).mockImplementation(async (p) => { const entry = map.get(p as string); if (entry) return await Promise.resolve(entry as Stats); throw enoent(); @@ -33,7 +34,7 @@ function mockLstat(entries: Array<[string, Partial]>): void { function mockReadlink(entries: Array<[string, string]>): void { const map = new Map(entries); - (fs.readlink as jest.Mock).mockImplementation(async (p: string) => { + (fs.readlink as Mock).mockImplementation(async (p: string) => { if (map.has(p)) return await Promise.resolve(map.get(p)!); throw enoent(); }); @@ -41,10 +42,10 @@ function mockReadlink(entries: Array<[string, string]>): void { describe('resolveSafePath', () => { beforeEach(() => { - jest.resetAllMocks(); + vi.resetAllMocks(); // Default: only base exists; everything else is ENOENT mockRealpath([[BASE, BASE]]); - jest.mocked(fs.lstat).mockRejectedValue(enoent()); + vi.mocked(fs.lstat).mockRejectedValue(enoent()); }); it('resolves a simple path within the base directory', async () => { @@ -147,7 +148,7 @@ describe('resolveSafePath', () => { const parentDir = settingsDir.replace(/\/[^/]+$/, ''); const sneakyLink = `${parentDir}/sneaky-link`; // Identity realpath by default; only the symlink diverges - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === sneakyLink) return await Promise.resolve(settingsDir); return await Promise.resolve(p); }); @@ -174,8 +175,8 @@ describe('isLikelyBinaryContent', () => { describe('resolveReadablePath', () => { beforeEach(() => { - jest.resetAllMocks(); - jest.mocked(fs.lstat).mockRejectedValue(enoent()); + vi.resetAllMocks(); + vi.mocked(fs.lstat).mockRejectedValue(enoent()); }); it('throws when a symlink resolves into an excluded directory segment', async () => { @@ -195,10 +196,10 @@ describe('buildFilesystemResource — settings self-protection', () => { const settingsFile = config.getSettingsFilePath(); beforeEach(() => { - jest.resetAllMocks(); + vi.resetAllMocks(); // Base dir is the settings directory's parent (so the path is reachable) const parentDir = settingsDir.replace(/\/[^/]+$/, ''); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === parentDir) return await Promise.resolve(parentDir); throw enoent(); }); @@ -221,7 +222,7 @@ describe('buildFilesystemResource — settings self-protection', () => { }); it('does not throw for unrelated paths', async () => { - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === BASE) return await Promise.resolve(BASE); throw enoent(); }); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/get-file-tree.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/get-file-tree.test.ts index ef9d341cd9d..79b8a71c5a8 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/get-file-tree.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/get-file-tree.test.ts @@ -1,10 +1,11 @@ import type { Dirent, Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { getFileTreeTool } from './get-file-tree'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; @@ -24,11 +25,11 @@ function dirent(name: string, isDir: boolean): Dirent { } function mockStat(size = 100): void { - jest.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); + vi.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); } function mockReaddir(...batches: Dirent[][]): void { - const mock = fs.readdir as jest.Mock; + const mock = fs.readdir as Mock; for (const batch of batches) { mock.mockResolvedValueOnce(batch); } @@ -36,8 +37,8 @@ function mockReaddir(...batches: Dirent[][]): void { describe('getFileTreeTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/list-files.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/list-files.test.ts index c89216f265a..73a8b4983b2 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/list-files.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/list-files.test.ts @@ -1,10 +1,11 @@ import type { Dirent, Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { listFilesTool } from './list-files'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; @@ -24,17 +25,17 @@ function dirent(name: string, isDir: boolean): Dirent { } function mockReaddir(entries: Dirent[]): void { - (fs.readdir as jest.Mock).mockResolvedValue(entries); + (fs.readdir as Mock).mockResolvedValue(entries); } function mockStat(size = 100): void { - jest.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); + vi.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); } describe('listFilesTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); @@ -172,7 +173,7 @@ describe('listFilesTool', () => { it('includes sizeBytes for files', async () => { mockReaddir([dirent('hello.txt', false)]); - jest.mocked(fs.stat).mockResolvedValue({ size: 5 } as unknown as Stats); + vi.mocked(fs.stat).mockResolvedValue({ size: 5 } as unknown as Stats); const result = await listFilesTool.execute({ dirPath: '.' }, CONTEXT); // eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse diff --git a/packages/@n8n/computer-use/src/tools/filesystem/move.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/move.test.ts index 68cd692440c..42239994204 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/move.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/move.test.ts @@ -1,24 +1,25 @@ import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { moveFileTool } from './move'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; function mockMkdir(): void { - (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.mkdir as Mock).mockResolvedValue(undefined); } function mockRename(): void { - jest.mocked(fs.rename).mockResolvedValue(undefined); + vi.mocked(fs.rename).mockResolvedValue(undefined); } describe('moveFileTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/read-file.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/read-file.test.ts index 499acfb528f..58084b416b6 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/read-file.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/read-file.test.ts @@ -1,25 +1,26 @@ import type { Stats } from 'node:fs'; import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { readFileTool } from './read-file'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; function mockStat(size: number): void { - jest.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); + vi.mocked(fs.stat).mockResolvedValue({ size } as unknown as Stats); } function mockReadFile(content: Buffer | string): void { - (fs.readFile as jest.Mock).mockResolvedValue(content); + (fs.readFile as Mock).mockResolvedValue(content); } describe('readFileTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); diff --git a/packages/@n8n/computer-use/src/tools/filesystem/write-file.test.ts b/packages/@n8n/computer-use/src/tools/filesystem/write-file.test.ts index 260a5720564..d5dadd4be30 100644 --- a/packages/@n8n/computer-use/src/tools/filesystem/write-file.test.ts +++ b/packages/@n8n/computer-use/src/tools/filesystem/write-file.test.ts @@ -1,24 +1,25 @@ import * as fs from 'node:fs/promises'; +import type { Mock } from 'vitest'; import { textOf } from '../test-utils'; import { writeFileTool } from './write-file'; -jest.mock('node:fs/promises'); +vi.mock('node:fs/promises'); const CONTEXT = { dir: '/base' }; function mockMkdir(): void { - (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.mkdir as Mock).mockResolvedValue(undefined); } function mockWriteFile(): void { - (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + (fs.writeFile as Mock).mockResolvedValue(undefined); } describe('writeFileTool', () => { beforeEach(() => { - jest.resetAllMocks(); - (fs.realpath as jest.Mock).mockImplementation(async (p: string) => { + vi.resetAllMocks(); + (fs.realpath as Mock).mockImplementation(async (p: string) => { if (p === '/base') return await Promise.resolve('/base'); throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); }); diff --git a/packages/@n8n/computer-use/src/tools/mouse-keyboard/mouse-keyboard.test.ts b/packages/@n8n/computer-use/src/tools/mouse-keyboard/mouse-keyboard.test.ts index 446954f1450..9069dc077bf 100644 --- a/packages/@n8n/computer-use/src/tools/mouse-keyboard/mouse-keyboard.test.ts +++ b/packages/@n8n/computer-use/src/tools/mouse-keyboard/mouse-keyboard.test.ts @@ -1,4 +1,5 @@ import robot from '@jitsi/robotjs'; +import type { Mock, Mocked } from 'vitest'; import { MouseKeyboardModule } from './index'; import { @@ -12,32 +13,32 @@ import { keyboardShortcutTool, } from './mouse-keyboard'; -jest.mock('@jitsi/robotjs', () => ({ +vi.mock('@jitsi/robotjs', () => ({ __esModule: true, default: { - moveMouse: jest.fn(), - mouseClick: jest.fn(), - mouseToggle: jest.fn(), - dragMouse: jest.fn(), - scrollMouse: jest.fn(), - typeString: jest.fn(), - typeStringDelayed: jest.fn(), - keyTap: jest.fn(), - getMousePos: jest.fn(), + moveMouse: vi.fn(), + mouseClick: vi.fn(), + mouseToggle: vi.fn(), + dragMouse: vi.fn(), + scrollMouse: vi.fn(), + typeString: vi.fn(), + typeStringDelayed: vi.fn(), + keyTap: vi.fn(), + getMousePos: vi.fn(), }, })); -jest.mock('../monitor-utils', () => ({ - getPrimaryMonitor: jest.fn().mockResolvedValue({ width: () => 1920, height: () => 1080 }), +vi.mock('../monitor-utils', () => ({ + getPrimaryMonitor: vi.fn().mockResolvedValue({ width: () => 1920, height: () => 1080 }), })); -const mockRobot = robot as jest.Mocked; +const mockRobot = robot as Mocked; const DUMMY_CONTEXT = { dir: '/test/base' }; const OK_RESULT = { content: [{ type: 'text' as const, text: 'ok' }] }; afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('mouse_move', () => { @@ -110,11 +111,11 @@ describe('mouse_double_click', () => { describe('mouse_drag', () => { it('moves, toggles down, drags, toggles up in order', async () => { const callOrder: string[] = []; - (mockRobot.moveMouse as jest.Mock).mockImplementation(() => callOrder.push('moveMouse')); - (mockRobot.mouseToggle as jest.Mock).mockImplementation((dir: string) => + (mockRobot.moveMouse as Mock).mockImplementation(() => callOrder.push('moveMouse')); + (mockRobot.mouseToggle as Mock).mockImplementation((dir: string) => callOrder.push(`toggle-${dir}`), ); - (mockRobot.dragMouse as jest.Mock).mockImplementation(() => callOrder.push('dragMouse')); + (mockRobot.dragMouse as Mock).mockImplementation(() => callOrder.push('dragMouse')); const result = await mouseDragTool.execute( { fromX: 10, fromY: 20, toX: 100, toY: 200 }, @@ -179,22 +180,22 @@ describe('keyboard_type', () => { }); it('waits for delayMs before typing', async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); const promise = keyboardTypeTool.execute({ text: 'delayed', delayMs: 500 }, DUMMY_CONTEXT); // Allow the dynamic import microtask to resolve before checking - await jest.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(0); // typeStringDelayed should not have been called yet (still waiting on setTimeout) expect(mockRobot.typeStringDelayed).not.toHaveBeenCalled(); - await jest.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); await promise; expect(mockRobot.typeStringDelayed).toHaveBeenCalledWith('delayed', expect.any(Number)); - jest.useRealTimers(); + vi.useRealTimers(); }); it('types immediately when delayMs is 0', async () => { @@ -268,7 +269,7 @@ describe('MouseKeyboardModule.isSupported', () => { } else { process.env.DISPLAY = originalDisplay; } - jest.resetModules(); + vi.resetModules(); }); it('returns false when WAYLAND_DISPLAY is set and DISPLAY is not', async () => { @@ -294,21 +295,20 @@ describe('MouseKeyboardModule.isSupported', () => { delete process.env.WAYLAND_DISPLAY; process.env.DISPLAY = ':0'; - let result: boolean | undefined; - - await jest.isolateModulesAsync(async () => { - jest.doMock('@jitsi/robotjs', () => ({ - __esModule: true, - default: { - getMousePos: () => { - throw new Error('Native module error'); - }, + vi.resetModules(); + vi.doMock('@jitsi/robotjs', () => ({ + __esModule: true, + default: { + getMousePos: () => { + throw new Error('Native module error'); }, - })); + }, + })); - const { MouseKeyboardModule: IsolatedModule } = await import('./index'); - result = await IsolatedModule.isSupported(); - }); + const { MouseKeyboardModule: IsolatedModule } = await import('./index'); + const result = await IsolatedModule.isSupported(); + + vi.doUnmock('@jitsi/robotjs'); expect(result).toBe(false); }); diff --git a/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts b/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts index 3093ab1224a..0cc2905eeda 100644 --- a/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts +++ b/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts @@ -1,37 +1,39 @@ import { Monitor } from 'node-screenshots'; +import type { Mock, MockedClass } from 'vitest'; import { ScreenshotModule } from './index'; import { screenshotTool, screenshotRegionTool } from './screenshot'; -jest.mock('node-screenshots'); +vi.mock('node-screenshots'); -const mockFromRgbaPixels = jest.fn(); -jest.mock('@napi-rs/image', () => ({ +const { mockFromRgbaPixels } = vi.hoisted(() => ({ + mockFromRgbaPixels: vi.fn<(...args: unknown[]) => unknown>(), +})); +vi.mock('@napi-rs/image', () => ({ __esModule: true, // eslint-disable-next-line @typescript-eslint/naming-convention Transformer: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return fromRgbaPixels: (...args: unknown[]) => mockFromRgbaPixels(...args), }, })); -const MockMonitor = Monitor as jest.MockedClass; +const MockMonitor = Monitor as MockedClass; const DUMMY_CONTEXT = { dir: '/test/base' }; interface MockImage { width: number; height: number; - toRaw: jest.Mock; - crop: jest.Mock; + toRaw: Mock; + crop: Mock; } function makeMockImage(width = 1920, height = 1080, rawData = 'fake-raw-bytes'): MockImage { const image: MockImage = { width, height, - toRaw: jest.fn().mockResolvedValue(Buffer.from(rawData)), - crop: jest.fn(), + toRaw: vi.fn().mockResolvedValue(Buffer.from(rawData)), + crop: vi.fn(), }; // Default crop returns a new cropped image // eslint-disable-next-line @typescript-eslint/promise-function-async @@ -39,21 +41,21 @@ function makeMockImage(width = 1920, height = 1080, rawData = 'fake-raw-bytes'): Promise.resolve({ width: w, height: h, - toRaw: jest.fn().mockResolvedValue(Buffer.from(`cropped-${w}x${h}`)), - crop: jest.fn(), + toRaw: vi.fn().mockResolvedValue(Buffer.from(`cropped-${w}x${h}`)), + crop: vi.fn(), }), ); return image; } interface MockMonitorInstance { - isPrimary: jest.Mock; - x: jest.Mock; - y: jest.Mock; - width: jest.Mock; - height: jest.Mock; - scaleFactor: jest.Mock; - captureImage: jest.Mock; + isPrimary: Mock; + x: Mock; + y: Mock; + width: Mock; + height: Mock; + scaleFactor: Mock; + captureImage: Mock; } function makeMockMonitor(opts: { @@ -67,19 +69,19 @@ function makeMockMonitor(opts: { }): MockMonitorInstance { const image = opts.image ?? makeMockImage(); return { - isPrimary: jest.fn().mockReturnValue(opts.isPrimary ?? false), - x: jest.fn().mockReturnValue(opts.x ?? 0), - y: jest.fn().mockReturnValue(opts.y ?? 0), - width: jest.fn().mockReturnValue(opts.width ?? 1920), - height: jest.fn().mockReturnValue(opts.height ?? 1080), - scaleFactor: jest.fn().mockReturnValue(opts.scaleFactor ?? 1.0), - captureImage: jest.fn().mockResolvedValue(image), + isPrimary: vi.fn().mockReturnValue(opts.isPrimary ?? false), + x: vi.fn().mockReturnValue(opts.x ?? 0), + y: vi.fn().mockReturnValue(opts.y ?? 0), + width: vi.fn().mockReturnValue(opts.width ?? 1920), + height: vi.fn().mockReturnValue(opts.height ?? 1080), + scaleFactor: vi.fn().mockReturnValue(opts.scaleFactor ?? 1.0), + captureImage: vi.fn().mockResolvedValue(image), }; } beforeEach(() => { - const mockJpeg = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg')); - const mockResize = jest.fn(); + const mockJpeg = vi.fn().mockResolvedValue(Buffer.from('fake-jpeg')); + const mockResize = vi.fn(); const pipeline = { resize: mockResize, jpeg: mockJpeg }; mockResize.mockReturnValue(pipeline); mockFromRgbaPixels.mockReturnValue(pipeline); @@ -87,12 +89,12 @@ beforeEach(() => { describe('screen_screenshot tool', () => { afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('returns base64 JPEG as media content for primary monitor', async () => { const monitor = makeMockMonitor({ isPrimary: true, width: 1920, height: 1080 }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); const result = await screenshotTool.execute({}, DUMMY_CONTEXT); @@ -107,7 +109,7 @@ describe('screen_screenshot tool', () => { it('uses the primary monitor when multiple monitors are available', async () => { const secondary = makeMockMonitor({ isPrimary: false, x: 1920 }); const primary = makeMockMonitor({ isPrimary: true, x: 0 }); - (MockMonitor.all as jest.Mock).mockReturnValue([secondary, primary]); + (MockMonitor.all as Mock).mockReturnValue([secondary, primary]); await screenshotTool.execute({}, DUMMY_CONTEXT); @@ -116,7 +118,7 @@ describe('screen_screenshot tool', () => { }); it('throws when no monitors are available', async () => { - (MockMonitor.all as jest.Mock).mockReturnValue([]); + (MockMonitor.all as Mock).mockReturnValue([]); await expect(screenshotTool.execute({}, DUMMY_CONTEXT)).rejects.toThrow( 'No monitors available', @@ -133,11 +135,11 @@ describe('screen_screenshot tool', () => { scaleFactor: 2.0, image, }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); await screenshotTool.execute({}, DUMMY_CONTEXT); - const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock }; + const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: Mock }; expect(pipeline.resize).toHaveBeenCalledWith(1920, 1080); }); @@ -148,11 +150,11 @@ describe('screen_screenshot tool', () => { height: 1080, scaleFactor: 1.0, }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); await screenshotTool.execute({}, DUMMY_CONTEXT); - const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock }; + const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: Mock }; // No HiDPI resize, but LLM downscale kicks in (1920x1080 → 1024x576) expect(pipeline.resize).toHaveBeenCalledWith(1024, 576); }); @@ -160,12 +162,12 @@ describe('screen_screenshot tool', () => { describe('screen_screenshot_region tool', () => { afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('returns cropped image data as media content', async () => { const monitor = makeMockMonitor({ isPrimary: true, x: 0, y: 0, width: 1920, height: 1080 }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); const result = await screenshotRegionTool.execute( { x: 100, y: 200, width: 400, height: 300 }, @@ -190,7 +192,7 @@ describe('screen_screenshot_region tool', () => { height: 1440, image, }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); await screenshotRegionTool.execute({ x: 2000, y: 200, width: 300, height: 200 }, DUMMY_CONTEXT); @@ -208,7 +210,7 @@ describe('screen_screenshot_region tool', () => { height: 1080, image, }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); await screenshotRegionTool.execute({ x: 100, y: 100, width: 200, height: 150 }, DUMMY_CONTEXT); @@ -228,7 +230,7 @@ describe('screen_screenshot_region tool', () => { scaleFactor: 2.0, image, }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); // Input in logical pixels: x=100, y=200, w=400, h=300 await screenshotRegionTool.execute({ x: 100, y: 200, width: 400, height: 300 }, DUMMY_CONTEXT); @@ -248,31 +250,31 @@ describe('screen_screenshot_region tool', () => { scaleFactor: 2.0, image, }); - (MockMonitor.all as jest.Mock).mockReturnValue([monitor]); + (MockMonitor.all as Mock).mockReturnValue([monitor]); await screenshotRegionTool.execute({ x: 100, y: 200, width: 400, height: 300 }, DUMMY_CONTEXT); // Cropped image (800×600 physical) must be resized to logical 400×300 - const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock }; + const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: Mock }; expect(pipeline.resize).toHaveBeenCalledWith(400, 300); }); }); describe('ScreenshotModule.isSupported', () => { afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it.each([ ['has monitors', [{}], true], ['returns empty array', [], false], ])('returns %s -> %s', async (_label, monitorList, expected) => { - (MockMonitor.all as jest.Mock).mockReturnValue(monitorList); + (MockMonitor.all as Mock).mockReturnValue(monitorList); await expect(ScreenshotModule.isSupported()).resolves.toBe(expected); }); it('returns false when Monitor.all() throws', async () => { - (MockMonitor.all as jest.Mock).mockImplementation(() => { + (MockMonitor.all as Mock).mockImplementation(() => { throw new Error('Display server unavailable'); }); await expect(ScreenshotModule.isSupported()).resolves.toBe(false); diff --git a/packages/@n8n/computer-use/src/tools/shell/shell-execute.test.ts b/packages/@n8n/computer-use/src/tools/shell/shell-execute.test.ts index d081c541d7e..7eea40ffdb7 100644 --- a/packages/@n8n/computer-use/src/tools/shell/shell-execute.test.ts +++ b/packages/@n8n/computer-use/src/tools/shell/shell-execute.test.ts @@ -1,6 +1,7 @@ import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; import { spawn } from 'child_process'; import { EventEmitter } from 'events'; +import type { Mock, Mocked, MockedFunction } from 'vitest'; import { getSettingsDir } from '../../config'; import { textOf } from '../test-utils'; @@ -9,21 +10,19 @@ import { buildShellResource } from './build-shell-resource'; import { ShellModule } from './index'; import { shellExecuteTool } from './shell-execute'; -jest.mock('child_process'); -jest.mock('@vscode/ripgrep', () => ({ rgPath: '/usr/bin/rg' })); -jest.mock('@anthropic-ai/sandbox-runtime', () => ({ +vi.mock('child_process'); +vi.mock('@vscode/ripgrep', () => ({ rgPath: '/usr/bin/rg' })); +vi.mock('@anthropic-ai/sandbox-runtime', () => ({ // eslint-disable-next-line SandboxManager: { - initialize: jest.fn().mockResolvedValue(undefined), - wrapWithSandbox: jest - .fn() - .mockImplementation(async (cmd: string) => await Promise.resolve(cmd)), + initialize: vi.fn().mockResolvedValue(undefined), + wrapWithSandbox: vi.fn().mockImplementation(async (cmd: string) => await Promise.resolve(cmd)), }, })); -const mockSandboxManager = SandboxManager as jest.Mocked; +const mockSandboxManager = SandboxManager as Mocked; -const mockSpawn = spawn as jest.MockedFunction; +const mockSpawn = spawn as MockedFunction; const DUMMY_CONTEXT = { dir: '/test/base' }; @@ -31,25 +30,25 @@ function makeMockChild( overrides: Partial<{ stdout: EventEmitter; stderr: EventEmitter; - kill: jest.Mock; - on: jest.Mock; + kill: Mock; + on: Mock; }> = {}, ) { const stdout = overrides.stdout ?? new EventEmitter(); const stderr = overrides.stderr ?? new EventEmitter(); - const kill = overrides.kill ?? jest.fn(); - const on = overrides.on ?? jest.fn(); + const kill = overrides.kill ?? vi.fn(); + const on = overrides.on ?? vi.fn(); return { stdout, stderr, kill, on }; } -function getCloseHandler(on: jest.Mock): ((code: number) => void) | undefined { +function getCloseHandler(on: Mock): ((code: number) => void) | undefined { const call = on.mock.calls.find((args: unknown[]) => args[0] === 'close') as | [string, (code: number) => void] | undefined; return call?.[1]; } -function getErrorHandler(on: jest.Mock): ((error: Error) => void) | undefined { +function getErrorHandler(on: Mock): ((error: Error) => void) | undefined { const call = on.mock.calls.find((args: unknown[]) => args[0] === 'error') as | [string, (error: Error) => void] | undefined; @@ -70,7 +69,7 @@ describe('shell_execute tool', () => { }); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); }); @@ -162,7 +161,7 @@ describe('shell_execute tool', () => { }); it('kills the child and returns timedOut:true when timeout is exceeded', async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); const child = makeMockChild(); mockSpawn.mockReturnValue(child as unknown as ReturnType); @@ -173,7 +172,7 @@ describe('shell_execute tool', () => { await flushMicrotasks(); - jest.advanceTimersByTime(1001); + vi.advanceTimersByTime(1001); const result = await resultPromise; // eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse @@ -188,7 +187,7 @@ describe('shell_execute tool', () => { expect(parsed.exitCode).toBeNull(); expect(child.kill).toHaveBeenCalled(); - jest.useRealTimers(); + vi.useRealTimers(); }); it('resolves with an error result when spawn emits an error event', async () => { diff --git a/packages/@n8n/computer-use/tsconfig.json b/packages/@n8n/computer-use/tsconfig.json index 8036ebff7f6..d41b2ba23b0 100644 --- a/packages/@n8n/computer-use/tsconfig.json +++ b/packages/@n8n/computer-use/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/computer-use/vite.config.ts b/packages/@n8n/computer-use/vite.config.ts new file mode 100644 index 00000000000..a46f15be8dd --- /dev/null +++ b/packages/@n8n/computer-use/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path'; +import { mergeConfig } from 'vite'; +import { createVitestConfig } from '@n8n/vitest-config/node'; + +export default mergeConfig( + createVitestConfig({ + // The n8n root jest.config sets `restoreMocks: true`, and tests rely on it. + restoreMocks: true, + }), + { + resolve: { + alias: [ + // @inquirer/prompts and its sub-packages are ESM-only. Tests redirect + // any @inquirer/* import to this mock (mirrors the former Jest + // moduleNameMapper). + { + find: /^@inquirer\/.*$/, + replacement: path.resolve(__dirname, './src/__mocks__/@inquirer/prompts.ts'), + }, + ], + }, + }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf5ff909259..c38667c494a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1379,12 +1379,21 @@ importers: '@n8n/typescript-config': specifier: workspace:* version: link:../typescript-config + '@n8n/vitest-config': + specifier: workspace:* + version: link:../vitest-config '@types/node': specifier: ^20.17.50 version: 20.19.21 '@types/yargs-parser': specifier: 21.0.0 version: 21.0.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.1(vitest@4.1.1) + vitest: + specifier: 'catalog:' + version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(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/config: dependencies: