test: Migrate @n8n/computer-use from Jest to Vitest (#31482)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-06-03 16:42:19 +03:00 committed by GitHub
parent a3cb0c801a
commit 5feafdfafd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 293 additions and 254 deletions

View File

@ -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/(.*)$': '<rootDir>/src/__mocks__/@inquirer/prompts.ts',
},
};

View File

@ -18,9 +18,9 @@
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false"
},
"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:"
}
}

View File

@ -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();

View File

@ -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<GatewaySession> = {}): jest.Mocked<GatewaySession> {
function makeSession(overrides: Partial<GatewaySession> = {}): Mocked<GatewaySession> {
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<GatewaySession>;
} as unknown as Mocked<GatewaySession>;
}
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<GatewaySession>,
session: Mocked<GatewaySession>,
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 } }),
});
}

View File

@ -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<string, string[]>;
deny: Record<string, string[]>;
}> = {},
): jest.Mocked<
Pick<SettingsStore, 'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'flush'>
> {
): Mocked<Pick<SettingsStore, 'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'flush'>> {
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),
};
}

View File

@ -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<string, unknown>] | undefined = (
spy.mock.calls as Array<[string, Record<string, unknown>]>
).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(() => {

View File

@ -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<typeof os>('node:os');
return { ...actual, homedir: jest.fn(() => actual.homedir()) };
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof os>('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<string, unknown>,
): Promise<SettingsStore> {
(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 });

View File

@ -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<typeof os>('node:os');
return { ...actual, homedir: jest.fn(() => actual.homedir()) };
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof os>('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 });
});

View File

@ -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' });
});

View File

@ -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);

View File

@ -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);

View File

@ -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' });
});

View File

@ -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<Stats>]>): 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<Stats>]>): 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();
});

View File

@ -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' });
});

View File

@ -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

View File

@ -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' });
});

View File

@ -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' });
});

View File

@ -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' });
});

View File

@ -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<typeof robot>;
const mockRobot = robot as Mocked<typeof robot>;
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);
});

View File

@ -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<unknown, unknown[]>();
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<typeof Monitor>;
const MockMonitor = Monitor as MockedClass<typeof Monitor>;
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);

View File

@ -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<typeof SandboxManager>;
const mockSandboxManager = SandboxManager as Mocked<typeof SandboxManager>;
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
const mockSpawn = spawn as MockedFunction<typeof spawn>;
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<typeof spawn>);
@ -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 () => {

View File

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

View File

@ -0,0 +1,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'),
},
],
},
},
);

View File

@ -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: