test: Migrate @n8n/db from Jest to Vitest (no-changelog) (#31560)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-06-04 09:17:59 +03:00 committed by GitHub
parent ea800f715d
commit d7d2071bdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 360 additions and 180 deletions

View File

@ -1,10 +0,0 @@
const baseConfig = require('../../../jest.config');
/** @type {import('jest').Config} */
module.exports = {
...baseConfig,
transform: {
...baseConfig.transform,
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
},
};

View File

@ -12,9 +12,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",
"migration:new": "node scripts/new-migration.mjs"
},
"main": "dist/index.js",
@ -47,7 +47,12 @@
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/lodash": "catalog:",
"express": "5.1.0"
"@vitest/coverage-v8": "catalog:",
"express": "5.1.0",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
}
}

View File

@ -2,38 +2,39 @@
import type { Logger } from '@n8n/backend-common';
import type { DatabaseConfig } from '@n8n/config';
import type { DataSource } from '@n8n/typeorm';
import { mock, mockDeep } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core';
import type TimersPromises from 'timers/promises';
import { setTimeout as setTimeoutP } from 'timers/promises';
import type { Mock, MockedFunction } from 'vitest';
import { mock, mockDeep } from 'vitest-mock-extended';
import { DbConnectionMonitor } from '../db-connection-monitor';
// The monitor uses `setTimeout` from `timers/promises` for recovery backoff.
// Mocking it lets us drive the recovery loop deterministically without juggling
// jest fake timers against async/await microtask ordering.
jest.mock('timers/promises', () => {
const actual = jest.requireActual<typeof TimersPromises>('timers/promises');
return { ...actual, setTimeout: jest.fn() };
// fake timers against async/await microtask ordering.
vi.mock('timers/promises', async () => {
const actual = await vi.importActual<typeof TimersPromises>('timers/promises');
return { ...actual, setTimeout: vi.fn() };
});
const mockedSetTimeoutP = setTimeoutP as jest.MockedFunction<typeof setTimeoutP>;
const mockedSetTimeoutP = setTimeoutP as MockedFunction<typeof setTimeoutP>;
const flushMicrotasks = async () => await new Promise((resolve) => setImmediate(resolve));
describe('DbConnectionMonitor', () => {
let monitor: DbConnectionMonitor;
let onConnectedChange: jest.MockedFunction<(connected: boolean) => void>;
let onConnectedChange: MockedFunction<(connected: boolean) => void>;
const errorReporter = mock<ErrorReporter>();
const databaseConfig = mock<DatabaseConfig>({ pingTimeoutMs: 5_000 });
const logger = mock<Logger>();
const dataSource = mockDeep<DataSource>({ options: { type: 'postgres' } });
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
// Default: never resolves, so query wins the ping timeout race and
// recovery backoff stays suspended unless a test overrides it.
mockedSetTimeoutP.mockImplementation(async () => await new Promise(() => {}));
onConnectedChange = jest.fn();
onConnectedChange = vi.fn();
monitor = new DbConnectionMonitor(
dataSource,
onConnectedChange,
@ -110,7 +111,7 @@ describe('DbConnectionMonitor', () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
dataSource.query.mockResolvedValue([{ '1': 1 }]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const scheduleNextPingSpy = jest.spyOn(monitor as any, 'scheduleNextPing');
const scheduleNextPingSpy = vi.spyOn(monitor as any, 'scheduleNextPing');
// @ts-expect-error private property
await monitor.ping();
@ -150,7 +151,7 @@ describe('DbConnectionMonitor', () => {
.mockRejectedValueOnce(
new Error('Client has encountered a connection error and is not queryable'),
);
const recoverSpy = jest
const recoverSpy = vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(monitor as any, 'recoverDataSource')
.mockResolvedValue(undefined);
@ -171,7 +172,7 @@ describe('DbConnectionMonitor', () => {
// @ts-expect-error readonly property
dataSource.isInitialized = true;
dataSource.query.mockRejectedValue(new Error('pool poisoned'));
const recoverSpy = jest
const recoverSpy = vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(monitor as any, 'recoverDataSource')
.mockResolvedValue(undefined);
@ -234,7 +235,7 @@ describe('DbConnectionMonitor', () => {
});
it('should execute ping on schedule', () => {
jest.useFakeTimers();
vi.useFakeTimers();
try {
const scheduledMonitor = new DbConnectionMonitor(
dataSource,
@ -244,20 +245,20 @@ describe('DbConnectionMonitor', () => {
errorReporter,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pingSpy = jest.spyOn(scheduledMonitor as any, 'ping');
const pingSpy = vi.spyOn(scheduledMonitor as any, 'ping');
// @ts-expect-error private property
scheduledMonitor.scheduleNextPing();
jest.advanceTimersByTime(1000);
vi.advanceTimersByTime(1000);
expect(pingSpy).toHaveBeenCalled();
} finally {
jest.useRealTimers();
vi.useRealTimers();
}
});
it('should not schedule another ping after stop', () => {
jest.useFakeTimers();
vi.useFakeTimers();
try {
const scheduledMonitor = new DbConnectionMonitor(
dataSource,
@ -267,16 +268,16 @@ describe('DbConnectionMonitor', () => {
errorReporter,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pingSpy = jest.spyOn(scheduledMonitor as any, 'ping');
const pingSpy = vi.spyOn(scheduledMonitor as any, 'ping');
scheduledMonitor.stop();
// @ts-expect-error private property
scheduledMonitor.scheduleNextPing();
jest.advanceTimersByTime(1000);
vi.advanceTimersByTime(1000);
expect(pingSpy).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
vi.useRealTimers();
}
});
});
@ -487,8 +488,8 @@ describe('DbConnectionMonitor', () => {
dataSource.isInitialized = true;
dataSource.destroy.mockResolvedValue();
dataSource.initialize.mockResolvedValue(dataSource);
const on = jest.fn();
(dataSource as unknown as { driver: { master: { on: jest.Mock } } }).driver = {
const on = vi.fn();
(dataSource as unknown as { driver: { master: { on: Mock } } }).driver = {
master: { on },
};
@ -531,7 +532,7 @@ describe('DbConnectionMonitor', () => {
};
it('should attach an error listener to the Postgres driver pool', () => {
const on = jest.fn();
const on = vi.fn();
setDriver({ master: { on } });
monitor.start();
@ -541,7 +542,7 @@ describe('DbConnectionMonitor', () => {
it('should mark the connection unhealthy when the pool emits an error', () => {
let handler: ((cause: unknown) => void) | undefined;
const on = jest.fn((_event: string, h: (cause: unknown) => void) => {
const on = vi.fn((_event: string, h: (cause: unknown) => void) => {
handler = h;
});
setDriver({ master: { on } });
@ -590,7 +591,7 @@ describe('DbConnectionMonitor', () => {
describe('stop', () => {
it('should clear the ping timer', () => {
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
// @ts-expect-error private property
monitor.pingTimer = setTimeout(() => {}, 1000);
@ -615,7 +616,7 @@ describe('DbConnectionMonitor', () => {
// initial state is "connected". If the default flipped to false, the first failed
// ping would be a no-op transition (false → false) and the owner's state machine
// would stay stuck at the manually-set `true` while reality is `false`.
const freshOnConnectedChange = jest.fn();
const freshOnConnectedChange = vi.fn();
const freshMonitor = new DbConnectionMonitor(
dataSource,
freshOnConnectedChange,

View File

@ -1,7 +1,7 @@
import type { ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig, InstanceSettingsConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import path from 'path';
import { mock } from 'vitest-mock-extended';
import { postgresMigrations } from '../../migrations/postgresdb';
import { sqliteMigrations } from '../../migrations/sqlite';
@ -24,7 +24,7 @@ describe('DbConnectionOptions', () => {
moduleRegistry,
);
beforeEach(() => jest.resetAllMocks());
beforeEach(() => vi.resetAllMocks());
const commonOptions = {
entityPrefix: 'test_prefix_',

View File

@ -2,9 +2,10 @@
import type { Logger } from '@n8n/backend-common';
import type { DatabaseConfig } from '@n8n/config';
import { DataSource, type DataSourceOptions } from '@n8n/typeorm';
import { mock, mockDeep } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core';
import { DbConnectionTimeoutError } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock, mockDeep } from 'vitest-mock-extended';
import * as migrationHelper from '../../migrations/migration-helpers';
import type { Migration } from '../../migrations/migration-types';
@ -12,14 +13,14 @@ import { DbConnection } from '../db-connection';
import { DbConnectionMonitor } from '../db-connection-monitor';
import type { DbConnectionOptions } from '../db-connection-options';
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
jest.mock('@n8n/typeorm', () => ({
vi.mock('@n8n/typeorm', async () => ({
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
...(await vi.importActual<typeof import('@n8n/typeorm')>('@n8n/typeorm')),
// eslint-disable-next-line @typescript-eslint/naming-convention
DataSource: jest.fn(),
...jest.requireActual('@n8n/typeorm'),
DataSource: vi.fn(),
}));
jest.mock('../db-connection-monitor');
vi.mock('../db-connection-monitor');
describe('DbConnection', () => {
let dbConnection: DbConnection;
@ -42,11 +43,15 @@ describe('DbConnection', () => {
const monitor = mock<DbConnectionMonitor>();
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
connectionOptions.getOptions.mockReturnValue(postgresOptions);
(DataSource as jest.Mock) = jest.fn().mockImplementation(() => dataSource);
jest.mocked(DbConnectionMonitor).mockImplementation(() => monitor);
vi.mocked(DbConnectionMonitor).mockImplementation(function () {
return monitor;
});
(DataSource as unknown as Mock) = vi.fn(function () {
return dataSource;
});
dbConnection = new DbConnection(errorReporter, connectionOptions, databaseConfig, logger);
});
@ -101,7 +106,9 @@ describe('DbConnection', () => {
it('should wrap migrations and run them', async () => {
dataSource.runMigrations.mockResolvedValue([]);
const wrapMigrationSpy = jest.spyOn(migrationHelper, 'wrapMigration').mockImplementation();
const wrapMigrationSpy = vi
.spyOn(migrationHelper, 'wrapMigration')
.mockImplementation(() => {});
expect(dataSource.runMigrations).not.toHaveBeenCalled();
expect(dbConnection.connectionState.migrated).toBe(false);

View File

@ -23,7 +23,7 @@ describe('migrationHelpers.wrapMigration', () => {
class TestMigration implements IrreversibleMigration {
async up() {}
}
const originalUp = jest.fn();
const originalUp = vi.fn();
TestMigration.prototype.up = originalUp;
//
@ -48,7 +48,7 @@ describe('migrationHelpers.wrapMigration', () => {
async down() {}
}
const originalDown = jest.fn();
const originalDown = vi.fn();
TestMigration.prototype.down = originalDown;
//

View File

@ -1,5 +1,5 @@
import type { Driver, QueryRunner, Table } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { Column } from '../column';
import { AddColumns, AddEnumCheck, CreateTable, DropEnumCheck } from '../table';

View File

@ -57,7 +57,7 @@ describe('Migration Helpers', () => {
const queryRunner = dataSource.createQueryRunner();
// Spy on queryRunner.query to capture the actual SQL being executed
const querySpy = jest.spyOn(queryRunner, 'query');
const querySpy = vi.spyOn(queryRunner, 'query');
// Copy all data using copyTable (should trigger multiple batches with OFFSET)
await copyTable(queryRunner, '', testTableName, destTableName);

View File

@ -13,7 +13,7 @@ describe('CredentialDependencyRepository', () => {
const repository = Container.get(CredentialDependencyRepository);
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe('findCredentialIdsByDependencyId', () => {
@ -39,11 +39,11 @@ describe('CredentialDependencyRepository', () => {
describe('upsertDependenciesForCredential', () => {
it('deduplicates ids and inserts once with orIgnore', async () => {
const qb = {
insert: jest.fn().mockReturnThis(),
into: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
orIgnore: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(undefined),
insert: vi.fn().mockReturnThis(),
into: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
orIgnore: vi.fn().mockReturnThis(),
execute: vi.fn().mockResolvedValue(undefined),
};
entityManager.createQueryBuilder.mockReturnValue(qb as never);
@ -89,7 +89,7 @@ describe('CredentialDependencyRepository', () => {
{ dependencyId: 'provider-keep' } as CredentialDependency,
]);
const upsertSpy = jest
const upsertSpy = vi
.spyOn(repository, 'upsertDependenciesForCredential')
.mockResolvedValue(undefined);
@ -158,7 +158,7 @@ describe('CredentialDependencyRepository', () => {
describe('addCredentialDependencyExistsFilter', () => {
it('applies the EXISTS dependency filter using andWhere', () => {
const qb = {
andWhere: jest.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
};
const filter = {
dependencyType: 'externalSecretProvider',

View File

@ -1,7 +1,7 @@
import { Container } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import { In } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { CredentialsEntity } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
@ -12,7 +12,7 @@ describe('CredentialsRepository', () => {
const credentialsRepository = Container.get(CredentialsRepository);
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe('findManyAndCount', () => {
@ -58,13 +58,13 @@ describe('CredentialsRepository', () => {
describe('findAllGlobalCredentials', () => {
it('applies dependency filter through query builder when provided', async () => {
const andWhereSpy = jest.fn().mockReturnThis();
const getManySpy = jest.fn().mockResolvedValue([]);
const andWhereSpy = vi.fn().mockReturnThis();
const getManySpy = vi.fn().mockResolvedValue([]);
const qb = mock<SelectQueryBuilder<CredentialsEntity>>({
andWhere: andWhereSpy,
getMany: getManySpy,
});
jest.spyOn(credentialsRepository, 'createQueryBuilder').mockReturnValue(qb);
vi.spyOn(credentialsRepository, 'createQueryBuilder').mockReturnValue(qb);
await credentialsRepository.findAllGlobalCredentials({
filters: {

View File

@ -1,4 +1,5 @@
import { Container } from '@n8n/di';
import type { Mock } from 'vitest';
import { EvaluationCollection } from '../../entities/evaluation-collection.ee';
import { TestRun } from '../../entities/test-run.ee';
@ -10,12 +11,12 @@ describe('EvaluationCollectionRepository', () => {
const repo = Container.get(EvaluationCollectionRepository);
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe('createCollection', () => {
it('persists the collection with insightsCache initialised to null', async () => {
(entityManager.create as jest.Mock).mockImplementation(
(entityManager.create as Mock).mockImplementation(
(_target: unknown, entityLike: unknown) => entityLike as EvaluationCollection,
);
entityManager.save.mockImplementationOnce(async (_target, entity) => entity);
@ -62,11 +63,11 @@ describe('EvaluationCollectionRepository', () => {
] as EvaluationCollection[];
entityManager.find.mockResolvedValueOnce(collections);
const qb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValueOnce([
select: vi.fn().mockReturnThis(),
addSelect: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
getRawMany: vi.fn().mockResolvedValueOnce([
{ collectionId: 'col-a', count: '3' },
{ collectionId: 'col-b', count: '0' },
]),
@ -185,7 +186,7 @@ describe('EvaluationCollectionRepository', () => {
workflowId: 'wf-1',
} as EvaluationCollection;
entityManager.findOne.mockResolvedValueOnce(existing);
(entityManager.create as jest.Mock).mockImplementation(
(entityManager.create as Mock).mockImplementation(
(_target: unknown, entityLike: unknown) => entityLike as EvaluationCollection,
);
entityManager.save.mockImplementationOnce(async (_target, entity) => entity);

View File

@ -1,5 +1,6 @@
import type { UpsertEvaluationConfigDto } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Mock } from 'vitest';
import { EvaluationConfig } from '../../entities/evaluation-config.ee';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
@ -33,7 +34,7 @@ describe('EvaluationConfigRepository', () => {
}) as UpsertEvaluationConfigDto;
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe('listByWorkflowId', () => {
@ -78,7 +79,7 @@ describe('EvaluationConfigRepository', () => {
describe('createForWorkflow', () => {
it('persists a new config with the supplied id and workflow id', async () => {
const payload = buildPayload();
(entityManager.create as jest.Mock).mockImplementation(
(entityManager.create as Mock).mockImplementation(
(_target: unknown, entityLike: unknown) => entityLike as EvaluationConfig,
);
entityManager.save.mockImplementationOnce(async (_target, entity) => entity);
@ -169,13 +170,13 @@ describe('EvaluationConfigRepository', () => {
describe('countDistinctWorkflowsWithConfigs', () => {
it('returns the count of distinct workflowIds that have at least one config', async () => {
const qbMock = {
select: jest.fn().mockReturnThis(),
distinct: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValueOnce(7),
select: vi.fn().mockReturnThis(),
distinct: vi.fn().mockReturnThis(),
getCount: vi.fn().mockResolvedValueOnce(7),
};
jest
.spyOn(repo, 'createQueryBuilder')
.mockReturnValueOnce(qbMock as unknown as ReturnType<typeof repo.createQueryBuilder>);
vi.spyOn(repo, 'createQueryBuilder').mockReturnValueOnce(
qbMock as unknown as ReturnType<typeof repo.createQueryBuilder>,
);
expect(await repo.countDistinctWorkflowsWithConfigs()).toBe(7);
expect(qbMock.select).toHaveBeenCalledWith('evaluation_config.workflowId');

View File

@ -4,10 +4,10 @@ import type { SqliteConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import { In, LessThan, LessThanOrEqual, And, Not } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { BinaryDataService } from 'n8n-core';
import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { mock } from 'vitest-mock-extended';
import { ExecutionEntity } from '../../entities';
import type { IExecutionResponse } from '../../entities/types-db';
@ -29,7 +29,7 @@ describe('ExecutionRepository', () => {
const executionRepository = Container.get(ExecutionRepository);
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe('getExecutionsForPublicApi', () => {
@ -403,7 +403,7 @@ describe('ExecutionRepository', () => {
},
} as unknown;
const updateSpy = jest.spyOn(executionRepository, 'updateExistingExecution');
const updateSpy = vi.spyOn(executionRepository, 'updateExistingExecution');
const result = await executionRepository.stopDuringRun(mockExecution as IExecutionResponse);
@ -511,8 +511,8 @@ describe('ExecutionRepository', () => {
describe('getWaitingExecutions', () => {
const mockDate = new Date('2023-12-28 12:34:56.789Z');
beforeAll(() => jest.useFakeTimers().setSystemTime(mockDate));
afterAll(() => jest.useRealTimers());
beforeAll(() => vi.useFakeTimers().setSystemTime(mockDate));
afterAll(() => vi.useRealTimers());
test.each(['sqlite', 'postgresdb'] as const)(
'on %s, should only return executions with status=waiting',
@ -543,11 +543,11 @@ describe('ExecutionRepository', () => {
const workflowId = nanoid();
const binaryDataService = Container.get(BinaryDataService);
jest.spyOn(executionRepository, 'createQueryBuilder').mockReturnValue(
vi.spyOn(executionRepository, 'createQueryBuilder').mockReturnValue(
mock<SelectQueryBuilder<ExecutionEntity>>({
select: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([{ id: '1', workflowId }]),
select: vi.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
getMany: vi.fn().mockResolvedValue([{ id: '1', workflowId }]),
}),
);
@ -578,7 +578,7 @@ describe('ExecutionRepository', () => {
status: 'success',
});
const txCallback = jest.fn();
const txCallback = vi.fn();
entityManager.transaction.mockImplementation(async (fn: unknown) => {
await (fn as (em: typeof entityManager) => Promise<unknown>)(entityManager);
txCallback();

View File

@ -1,6 +1,6 @@
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import random from 'lodash/random';
import { mock } from 'vitest-mock-extended';
import { SecretsProviderConnection } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
@ -24,7 +24,7 @@ describe('SecretsProviderConnectionRepository', () => {
};
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
});
describe('findAll', () => {

View File

@ -1,6 +1,7 @@
import { Container } from '@n8n/di';
import { In, type SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { SharedCredentials } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
@ -10,10 +11,10 @@ describe('SharedCredentialsRepository', () => {
const entityManager = mockEntityManager(SharedCredentials);
const sharedCredentialsRepository = Container.get(SharedCredentialsRepository);
let queryBuilder: jest.Mocked<SelectQueryBuilder<SharedCredentials>>;
let queryBuilder: Mocked<SelectQueryBuilder<SharedCredentials>>;
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
queryBuilder = mock<SelectQueryBuilder<SharedCredentials>>();
queryBuilder.where.mockReturnThis();
@ -21,7 +22,7 @@ describe('SharedCredentialsRepository', () => {
queryBuilder.innerJoin.mockReturnThis();
queryBuilder.select.mockReturnThis();
jest.spyOn(sharedCredentialsRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
vi.spyOn(sharedCredentialsRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
});
describe('findByCredentialIds', () => {

View File

@ -1,6 +1,7 @@
import { Container } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { SharedWorkflow } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
@ -10,10 +11,10 @@ describe('SharedWorkflowRepository', () => {
mockEntityManager(SharedWorkflow);
const sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
let queryBuilder: jest.Mocked<SelectQueryBuilder<SharedWorkflow>>;
let queryBuilder: Mocked<SelectQueryBuilder<SharedWorkflow>>;
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
queryBuilder = mock<SelectQueryBuilder<SharedWorkflow>>();
queryBuilder.where.mockReturnThis();
@ -21,7 +22,7 @@ describe('SharedWorkflowRepository', () => {
queryBuilder.innerJoin.mockReturnThis();
queryBuilder.select.mockReturnThis();
jest.spyOn(sharedWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
vi.spyOn(sharedWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
});
describe('getSharedPersonalWorkflowsCount', () => {

View File

@ -1,6 +1,7 @@
import { GlobalConfig } from '@n8n/config';
import { In, type SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import type { Mock, Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { WorkflowEntity } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
@ -26,10 +27,10 @@ describe('WorkflowRepository', () => {
workflowHistoryRepository,
);
let queryBuilder: jest.Mocked<SelectQueryBuilder<WorkflowEntity>>;
let queryBuilder: Mocked<SelectQueryBuilder<WorkflowEntity>>;
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
queryBuilder = mock<SelectQueryBuilder<WorkflowEntity>>();
@ -54,7 +55,7 @@ describe('WorkflowRepository', () => {
writable: true,
});
jest.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
vi.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
});
describe('applyNameFilter', () => {
@ -118,7 +119,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
// andWhere should not be called for name filter
const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
const nameFilterCalls = (queryBuilder.andWhere as Mock).mock.calls.filter((call) =>
call[0]?.includes('workflow.name'),
);
expect(nameFilterCalls).toHaveLength(0);
@ -133,7 +134,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
// andWhere should not be called for name filter
const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
const nameFilterCalls = (queryBuilder.andWhere as Mock).mock.calls.filter((call) =>
call[0]?.includes('workflow.name'),
);
expect(nameFilterCalls).toHaveLength(0);
@ -151,7 +152,7 @@ describe('WorkflowRepository', () => {
sharedWorkflowRepository,
workflowHistoryRepository,
);
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
vi.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
const workflowIds = ['workflow1'];
const options = {
@ -190,13 +191,13 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
const andWhereCall = (queryBuilder.andWhere as jest.Mock).mock.calls.find((call) =>
const andWhereCall = (queryBuilder.andWhere as Mock).mock.calls.find((call) =>
call[0]?.includes('workflow.name'),
);
expect(andWhereCall).toBeDefined();
expect(andWhereCall[0]).toContain('workflow.name');
expect(andWhereCall[0]).toContain('workflow.description');
expect(andWhereCall![0]).toContain('workflow.name');
expect(andWhereCall![0]).toContain('workflow.description');
});
it('should handle special characters in search query', async () => {
@ -293,7 +294,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, {});
const leftJoinCalls = (queryBuilder.leftJoin as jest.Mock).mock.calls.filter(
const leftJoinCalls = (queryBuilder.leftJoin as Mock).mock.calls.filter(
(call) => call[0] === 'workflow.activeVersion',
);
expect(leftJoinCalls).toHaveLength(0);
@ -307,7 +308,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
const leftJoinCalls = (queryBuilder.leftJoin as jest.Mock).mock.calls.filter(
const leftJoinCalls = (queryBuilder.leftJoin as Mock).mock.calls.filter(
(call) => call[0] === 'workflow.activeVersion',
);
expect(leftJoinCalls).toHaveLength(0);
@ -372,7 +373,7 @@ describe('WorkflowRepository', () => {
sharedWorkflowRepository,
workflowHistoryRepository,
);
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
vi.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
const workflowIds = ['workflow1'];
const options = {
@ -401,7 +402,7 @@ describe('WorkflowRepository', () => {
sharedWorkflowRepository,
workflowHistoryRepository,
);
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
vi.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
const workflowIds = ['workflow1'];
const options = {
@ -438,7 +439,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
// leftJoin should not be called for activeVersion since it's already joined
const activeVersionJoinCalls = (queryBuilder.leftJoin as jest.Mock).mock.calls.filter(
const activeVersionJoinCalls = (queryBuilder.leftJoin as Mock).mock.calls.filter(
(call) => call[0] === 'workflow.activeVersion',
);
expect(activeVersionJoinCalls).toHaveLength(0);
@ -458,7 +459,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
const triggerFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
const triggerFilterCalls = (queryBuilder.andWhere as Mock).mock.calls.filter((call) =>
call[0]?.includes?.('triggerNodeType'),
);
expect(triggerFilterCalls).toHaveLength(0);
@ -472,7 +473,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
const triggerFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
const triggerFilterCalls = (queryBuilder.andWhere as Mock).mock.calls.filter((call) =>
call[0]?.includes?.('triggerNodeType'),
);
expect(triggerFilterCalls).toHaveLength(0);
@ -486,7 +487,7 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
const triggerFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
const triggerFilterCalls = (queryBuilder.andWhere as Mock).mock.calls.filter((call) =>
call[0]?.includes?.('triggerNodeType'),
);
expect(triggerFilterCalls).toHaveLength(0);
@ -505,10 +506,10 @@ describe('WorkflowRepository', () => {
await workflowRepository.getMany(workflowIds, options);
// Should have called andWhere for both name and triggerNodeTypes filters
const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
const nameFilterCalls = (queryBuilder.andWhere as Mock).mock.calls.filter((call) =>
call[0]?.includes?.('workflow.name'),
);
const triggerFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
const triggerFilterCalls = (queryBuilder.andWhere as Mock).mock.calls.filter((call) =>
call[0]?.includes?.('triggerNodeType'),
);
expect(nameFilterCalls.length).toBeGreaterThan(0);
@ -521,7 +522,7 @@ describe('WorkflowRepository', () => {
describe('findByIds', () => {
it('should return an empty array and not call the database when no workflow ids are provided', async () => {
const findSpy = jest.spyOn(workflowRepository, 'find');
const findSpy = vi.spyOn(workflowRepository, 'find');
const workflowIds: string[] = [];
const result = await workflowRepository.findByIds(workflowIds);
@ -530,7 +531,7 @@ describe('WorkflowRepository', () => {
});
it('should call the database when workflow ids are provided', async () => {
const findSpy = jest.spyOn(workflowRepository, 'find').mockResolvedValue([]);
const findSpy = vi.spyOn(workflowRepository, 'find').mockResolvedValue([]);
const workflowIds = ['workflow1'];
const result = await workflowRepository.findByIds(workflowIds);
expect(result).toEqual([]);
@ -601,7 +602,7 @@ describe('WorkflowRepository', () => {
sharedWorkflowRepository,
workflowHistoryRepository,
);
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
vi.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
queryBuilder.getMany.mockResolvedValue([]);
const result = await sqliteWorkflowRepository.findByCredentialResolverId('resolver-123');
@ -624,13 +625,13 @@ describe('WorkflowRepository', () => {
describe('clearCredentialResolverId', () => {
it('should use PostgreSQL jsonb removal for postgresdb', async () => {
const mockExecute = jest.fn().mockResolvedValue({ affected: 1 });
const mockUpdateWhere = jest.fn().mockReturnValue({ execute: mockExecute });
const mockSet = jest.fn().mockReturnValue({ where: mockUpdateWhere });
const mockUpdate = jest.fn().mockReturnValue({ set: mockSet });
const mockExecute = vi.fn().mockResolvedValue({ affected: 1 });
const mockUpdateWhere = vi.fn().mockReturnValue({ execute: mockExecute });
const mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere });
const mockUpdate = vi.fn().mockReturnValue({ set: mockSet });
const updateQb = { update: mockUpdate } as unknown as SelectQueryBuilder<WorkflowEntity>;
jest.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(updateQb);
vi.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(updateQb);
await workflowRepository.clearCredentialResolverId('resolver-123');
@ -646,10 +647,10 @@ describe('WorkflowRepository', () => {
});
it('should use SQLite json_remove for sqlite', async () => {
const mockExecute = jest.fn().mockResolvedValue({ affected: 1 });
const mockUpdateWhere = jest.fn().mockReturnValue({ execute: mockExecute });
const mockSet = jest.fn().mockReturnValue({ where: mockUpdateWhere });
const mockUpdate = jest.fn().mockReturnValue({ set: mockSet });
const mockExecute = vi.fn().mockResolvedValue({ affected: 1 });
const mockUpdateWhere = vi.fn().mockReturnValue({ execute: mockExecute });
const mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere });
const mockUpdate = vi.fn().mockReturnValue({ set: mockSet });
const updateQb = { update: mockUpdate } as unknown as SelectQueryBuilder<WorkflowEntity>;
const sqliteConfig = mockInstance(GlobalConfig, {
@ -662,7 +663,7 @@ describe('WorkflowRepository', () => {
sharedWorkflowRepository,
workflowHistoryRepository,
);
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(updateQb);
vi.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(updateQb);
await sqliteWorkflowRepository.clearCredentialResolverId('resolver-123');

View File

@ -1,8 +1,8 @@
import type { DatabaseConfig } from '@n8n/config';
import { QueryFailedError } from '@n8n/typeorm';
import type { DataSource, EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { OperationalError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { DbLockService } from '../db-lock.service';
@ -11,12 +11,13 @@ describe('DbLockService', () => {
const dataSource = mock<DataSource>();
const databaseConfig = mock<DatabaseConfig>();
const transactionMock = jest.fn<Promise<unknown>, [(tx: EntityManager) => Promise<unknown>]>();
const transactionMock =
vi.fn<(...args: [(tx: EntityManager) => Promise<unknown>]) => Promise<unknown>>();
let service: DbLockService;
beforeEach(() => {
jest.resetAllMocks();
vi.resetAllMocks();
transactionMock.mockImplementation(async (fn) => await fn(mockTx));
dataSource.manager.transaction = transactionMock as never;
mockTx.query.mockResolvedValue([]);
@ -26,7 +27,7 @@ describe('DbLockService', () => {
describe('withLock', () => {
it('should acquire advisory lock and execute fn on Postgres', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn().mockResolvedValue('result');
const fn = vi.fn().mockResolvedValue('result');
const result = await service.withLock(1001, fn);
@ -37,7 +38,7 @@ describe('DbLockService', () => {
it('should skip advisory lock on SQLite and still execute fn', async () => {
databaseConfig.type = 'sqlite';
const fn = jest.fn().mockResolvedValue('result');
const fn = vi.fn().mockResolvedValue('result');
const result = await service.withLock(1001, fn);
@ -48,7 +49,7 @@ describe('DbLockService', () => {
it('should set lock_timeout when timeoutMs is provided', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn().mockResolvedValue('result');
const fn = vi.fn().mockResolvedValue('result');
await service.withLock(1001, fn, { timeoutMs: 5000 });
@ -58,7 +59,7 @@ describe('DbLockService', () => {
it('should not set lock_timeout when timeoutMs is not provided', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn().mockResolvedValue('result');
const fn = vi.fn().mockResolvedValue('result');
await service.withLock(1001, fn);
@ -68,7 +69,7 @@ describe('DbLockService', () => {
it('should throw OperationalError when lock timeout is exceeded', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn();
const fn = vi.fn();
const timeoutError = new QueryFailedError(
'SELECT pg_advisory_xact_lock($1)',
[1001],
@ -86,7 +87,7 @@ describe('DbLockService', () => {
it('should include timeout details in OperationalError message', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn();
const fn = vi.fn();
const timeoutError = new QueryFailedError(
'SELECT pg_advisory_xact_lock($1)',
[1001],
@ -102,7 +103,7 @@ describe('DbLockService', () => {
it('should propagate non-timeout errors unchanged', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn();
const fn = vi.fn();
const otherError = new Error('connection lost');
mockTx.query.mockRejectedValueOnce(otherError);
@ -115,7 +116,7 @@ describe('DbLockService', () => {
describe('tryWithLock', () => {
it('should execute fn when lock is acquired', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn().mockResolvedValue('result');
const fn = vi.fn().mockResolvedValue('result');
mockTx.query.mockResolvedValueOnce([{ pg_try_advisory_xact_lock: true }]);
const result = await service.tryWithLock(1001, fn);
@ -127,7 +128,7 @@ describe('DbLockService', () => {
it('should throw OperationalError when lock is already held', async () => {
databaseConfig.type = 'postgresdb';
const fn = jest.fn();
const fn = vi.fn();
mockTx.query.mockResolvedValueOnce([{ pg_try_advisory_xact_lock: false }]);
const error = await service.tryWithLock(1001, fn).catch((e: unknown) => e);
@ -140,7 +141,7 @@ describe('DbLockService', () => {
it('should skip advisory lock on SQLite and execute fn', async () => {
databaseConfig.type = 'sqlite';
const fn = jest.fn().mockResolvedValue('result');
const fn = vi.fn().mockResolvedValue('result');
const result = await service.tryWithLock(1001, fn);
@ -156,7 +157,7 @@ describe('DbLockService', () => {
});
afterEach(() => {
jest.useRealTimers();
vi.useRealTimers();
});
it('should serialize concurrent withLock calls for the same lockId', async () => {
@ -166,13 +167,13 @@ describe('DbLockService', () => {
resolveFirst = r;
});
const fn1 = jest.fn(async () => {
const fn1 = vi.fn(async () => {
executionOrder.push('fn1-start');
await firstBlocking;
executionOrder.push('fn1-end');
return 'first';
});
const fn2 = jest.fn(async () => {
const fn2 = vi.fn(async () => {
executionOrder.push('fn2-start');
return 'second';
});
@ -199,11 +200,11 @@ describe('DbLockService', () => {
resolveFirst = r;
});
const fn1 = jest.fn(async () => {
const fn1 = vi.fn(async () => {
await firstBlocking;
return 'first';
});
const fn2 = jest.fn(async () => 'second');
const fn2 = vi.fn(async () => 'second');
const p1 = service.withLock(1001, fn1);
const p2 = service.withLock(9999, fn2);
@ -220,23 +221,23 @@ describe('DbLockService', () => {
});
it('should reject with OperationalError when withLock timeout expires', async () => {
jest.useFakeTimers();
vi.useFakeTimers();
let resolveFirst!: () => void;
const firstBlocking = new Promise<void>((r) => {
resolveFirst = r;
});
const fn1 = jest.fn(async () => {
const fn1 = vi.fn(async () => {
await firstBlocking;
return 'first';
});
const fn2 = jest.fn().mockResolvedValue('second');
const fn2 = vi.fn().mockResolvedValue('second');
const p1 = service.withLock(1001, fn1);
const p2 = service.withLock(1001, fn2, { timeoutMs: 100 });
jest.advanceTimersByTime(100);
vi.advanceTimersByTime(100);
await expect(p2).rejects.toThrow(OperationalError);
await expect(p2).rejects.toThrow(/Timed out waiting for DbLock 1001 after 100ms/);
@ -252,11 +253,11 @@ describe('DbLockService', () => {
resolveFirst = r;
});
const fn1 = jest.fn(async () => {
const fn1 = vi.fn(async () => {
await firstBlocking;
return 'first';
});
const fn2 = jest.fn().mockResolvedValue('second');
const fn2 = vi.fn().mockResolvedValue('second');
const p1 = service.withLock(1001, fn1);
@ -273,8 +274,8 @@ describe('DbLockService', () => {
});
it('should release lock when fn throws in withLock', async () => {
const fn1 = jest.fn().mockRejectedValue(new Error('fn1 failed'));
const fn2 = jest.fn().mockResolvedValue('second');
const fn1 = vi.fn().mockRejectedValue(new Error('fn1 failed'));
const fn2 = vi.fn().mockResolvedValue('second');
await expect(service.withLock(1001, fn1)).rejects.toThrow('fn1 failed');
@ -283,8 +284,8 @@ describe('DbLockService', () => {
});
it('should release lock when fn throws in tryWithLock', async () => {
const fn1 = jest.fn().mockRejectedValue(new Error('fn1 failed'));
const fn2 = jest.fn().mockResolvedValue('second');
const fn1 = vi.fn().mockRejectedValue(new Error('fn1 failed'));
const fn2 = vi.fn().mockResolvedValue('second');
await expect(service.tryWithLock(1001, fn1)).rejects.toThrow('fn1 failed');
@ -298,12 +299,12 @@ describe('DbLockService', () => {
resolveFirst = r;
});
const fn1 = jest.fn(async () => {
const fn1 = vi.fn(async () => {
await firstBlocking;
return 'first';
});
const fn2 = jest.fn().mockResolvedValue('second');
const fn3 = jest.fn().mockResolvedValue('third');
const fn2 = vi.fn().mockResolvedValue('second');
const fn3 = vi.fn().mockResolvedValue('third');
const p1 = service.withLock(1001, fn1);
const p2 = service.withLock(1001, fn2);
@ -331,17 +332,17 @@ describe('DbLockService', () => {
resolve2 = r;
});
const fn1 = jest.fn(async () => {
const fn1 = vi.fn(async () => {
executionOrder.push('fn1');
await blocking1;
return 'first';
});
const fn2 = jest.fn(async () => {
const fn2 = vi.fn(async () => {
executionOrder.push('fn2');
await blocking2;
return 'second';
});
const fn3 = jest.fn(async () => {
const fn3 = vi.fn(async () => {
executionOrder.push('fn3');
return 'third';
});

View File

@ -5,9 +5,9 @@ import { applyWorkflowBooleanSettingFilter } from '../apply-workflow-boolean-set
function createMockQb() {
const qb = {
andWhere: jest.fn(),
where: jest.fn(),
orWhere: jest.fn(),
andWhere: vi.fn(),
where: vi.fn(),
orWhere: vi.fn(),
} as unknown as SelectQueryBuilder<object>;
return qb;
}

View File

@ -1,4 +1,4 @@
import { mock } from 'jest-mock-extended';
import { mock } from 'vitest-mock-extended';
import type { TestCaseExecution } from '../../entities';
import { getTestRunFinalResult } from '../get-final-test-result';

View File

@ -1,6 +1,6 @@
import { DataSource, EntityManager, type EntityMetadata } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import type { Class } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import { mockInstance } from './mock-instance';

View File

@ -1,5 +1,5 @@
import { Container, type Constructable } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { mock } from 'vitest-mock-extended';
export const mockInstance = <T>(
serviceClass: Constructable<T>,

View File

@ -7,5 +7,5 @@
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**"]
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.spec.ts"]
}

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",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,

View File

@ -0,0 +1,156 @@
import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
import { mergeConfig, type Plugin } from 'vite';
import { configDefaults } from 'vitest/config';
/**
* Vite plugin that transpiles this package's TypeORM entity files (`src/entities/**`)
* through the real TypeScript compiler (a full `ts.LanguageService`, not single-file
* `transpileModule`). Every other source file is left to Vite's fast oxc transform.
*
* TypeORM entities rely on `emitDecoratorMetadata` to derive column types from the
* reflected `design:type`. For a string-literal union column (e.g.
* `providerType: AuthProviderType`, where the alias is imported from another file),
* only `tsc` with cross-file type information collapses the union to `String`. Vite's
* oxc transform and SWC emit `Object` instead, which TypeORM rejects at
* `DataSource.initialize()`. Single-file `transpileModule` also emits `Object` because
* it can't resolve the imported alias. A full Program is required, which mirrors the
* old jest config that set `isolatedModules: false` for exactly this reason.
*
* Scoping to `src/entities/**` keeps the cost contained: only the ~50 entity files pay
* the tsc price (and the Program is rooted there), while DI `@Service` constructor
* metadata which oxc emits correctly keeps the fast path for the rest of `src`.
*/
function tscDecoratorTransform(): Plugin {
const projectDir = __dirname;
const entitiesDir = path.resolve(projectDir, 'src', 'entities') + path.sep;
let emit: ((fileName: string) => { code: string; map: unknown } | null) | undefined;
function createEmitter() {
const configPath = ts.findConfigFile(projectDir, ts.sys.fileExists, 'tsconfig.json');
if (!configPath) {
throw new Error('Could not find tsconfig.json for @n8n/db');
}
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, projectDir);
// Root the Program at the entity files only; their imported types (e.g. the union
// aliases in `types-db.ts`, related entities) are still resolved on demand via the
// host's snapshot reads, so cross-file metadata stays correct.
const rootFiles = parsed.fileNames.filter((f) => path.normalize(f).startsWith(entitiesDir));
const options: ts.CompilerOptions = {
...parsed.options,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
moduleResolution: ts.ModuleResolutionKind.Bundler,
experimentalDecorators: true,
emitDecoratorMetadata: true,
verbatimModuleSyntax: false,
isolatedModules: false,
sourceMap: true,
inlineSources: true,
skipLibCheck: true,
noEmit: false,
noEmitOnError: false,
declaration: false,
declarationMap: false,
composite: false,
incremental: false,
tsBuildInfoFile: undefined,
};
const versions = new Map<string, number>();
const contents = new Map<string, string>();
for (const f of rootFiles) {
versions.set(path.normalize(f), 0);
}
// Re-read `fileName` from disk and, if its content changed since the last read,
// bump the script version so the LanguageService invalidates its cached emit.
function refresh(fileName: string): string | undefined {
const norm = path.normalize(fileName);
const next = fs.existsSync(norm) ? fs.readFileSync(norm, 'utf-8') : undefined;
if (next !== contents.get(norm)) {
if (next === undefined) {
contents.delete(norm);
} else {
contents.set(norm, next);
}
versions.set(norm, (versions.get(norm) ?? 0) + 1);
}
return next;
}
const host: ts.LanguageServiceHost = {
getScriptFileNames: () => Array.from(versions.keys()),
getScriptVersion: (f) => String(versions.get(path.normalize(f)) ?? 0),
getScriptSnapshot: (f) => {
const cached = contents.get(path.normalize(f));
const text = cached ?? (fs.existsSync(f) ? fs.readFileSync(f, 'utf-8') : undefined);
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
},
getCurrentDirectory: () => projectDir,
getCompilationSettings: () => options,
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
directoryExists: ts.sys.directoryExists,
getDirectories: ts.sys.getDirectories,
};
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
return (fileName: string) => {
const norm = path.normalize(fileName);
// Pick up on-disk edits (watch mode) by bumping the script version when the
// content changes; otherwise the LanguageService reuses a stale cached emit.
if (refresh(norm) === undefined) {
return null;
}
const output = service.getEmitOutput(norm);
const js = output.outputFiles.find((f) => f.name.endsWith('.js'));
const map = output.outputFiles.find((f) => f.name.endsWith('.js.map'));
if (!js) {
return null;
}
return { code: js.text, map: map ? (JSON.parse(map.text) as unknown) : null };
};
}
return {
name: 'tsc-decorator-transform',
enforce: 'pre',
transform(_code, id) {
const file = id.split('?')[0];
if (!file.startsWith(entitiesDir) || !/\.tsx?$/.test(file)) return null;
emit ??= createEmitter();
const result = emit(file);
return result ? { code: result.code, map: result.map as never } : null;
},
};
}
export default mergeConfig(
createVitestConfigWithDecorators({
// The n8n root jest.config sets `restoreMocks: true`, and most test files silently
// rely on it — omit this and mocks bleed between tests.
restoreMocks: true,
}),
{
plugins: [tscDecoratorTransform()],
test: {
// Vitest 4's default exclude is only node_modules/.git — it does NOT cover dist.
// Without this, compiled test files left in dist (tsc never deletes orphaned
// output) get collected and fail (CJS `require('vitest')`). The build also
// excludes test files now, but this guards against pre-existing stale artifacts.
exclude: [...configDefaults.exclude, '**/dist/**'],
},
},
);

View File

@ -1531,12 +1531,27 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../vitest-config
'@types/lodash':
specifier: 'catalog:'
version: 4.17.17
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
express:
specifier: 5.1.0
version: 5.1.0
typescript:
specifier: 6.0.2
version: 6.0.2
vitest:
specifier: 'catalog:'
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
vitest-mock-extended:
specifier: 'catalog:'
version: 3.1.0(typescript@6.0.2)(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
packages/@n8n/decorators:
dependencies: