mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
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:
parent
a3cb0c801a
commit
5feafdfafd
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 } }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
23
packages/@n8n/computer-use/vite.config.ts
Normal file
23
packages/@n8n/computer-use/vite.config.ts
Normal 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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user