mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(core): Add automated node migrations to the v3 migration report (no-changelog) (#34803)
This commit is contained in:
committed by
GitHub
parent
acc62367c2
commit
81538fe23e
@@ -247,6 +247,7 @@ export type {
|
||||
BreakingChangeReportResult,
|
||||
BreakingChangeLightReportResult,
|
||||
BreakingChangeVersion,
|
||||
WorkflowMigrationResult,
|
||||
} from './schemas/breaking-changes.schema';
|
||||
|
||||
export { MIGRATION_REPORT_TARGET_VERSION } from './schemas/breaking-changes.schema';
|
||||
|
||||
@@ -59,6 +59,9 @@ const ruleResultBaseSchema = z.object({
|
||||
ruleSeverity: breakingChangeRuleSeveritySchema,
|
||||
ruleDocumentationUrl: z.string().optional(),
|
||||
recommendations: z.array(recommendationSchema),
|
||||
// True when an automated migration is registered for this rule, so the UI
|
||||
// can offer a "Migrate" action instead of prose-only advice.
|
||||
migratable: z.boolean(),
|
||||
});
|
||||
|
||||
const instanceRuleResultsSchema = ruleResultBaseSchema.extend({
|
||||
@@ -110,3 +113,21 @@ const breakingChangeLightReportResultDataSchema = z.object({
|
||||
export type BreakingChangeLightReportResult = z.infer<
|
||||
typeof breakingChangeLightReportResultDataSchema
|
||||
>;
|
||||
|
||||
// Result of applying an automated node migration to a single workflow.
|
||||
const workflowMigrationResultSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
// versionId of the new workflow version created by the migration.
|
||||
newVersionId: z.string(),
|
||||
// Ids of the nodes that were rewritten.
|
||||
migratedNodeIds: z.array(z.string()),
|
||||
// Parameters that could not be carried over (empty for lossless migrations).
|
||||
unmapped: z.array(z.string()),
|
||||
// Behavior/output changes the user should be aware of.
|
||||
notes: z.array(z.string()),
|
||||
// True when the migration can be published in one click: it was lossless (no
|
||||
// unmapped/notes), the migrated version was the one currently published, and
|
||||
// the result still validates for activation (real trigger, no node errors).
|
||||
republishable: z.boolean(),
|
||||
});
|
||||
export type WorkflowMigrationResult = z.infer<typeof workflowMigrationResultSchema>;
|
||||
|
||||
@@ -156,6 +156,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
|
||||
"chatHubAgent:delete",
|
||||
"chatHubAgent:list",
|
||||
"breakingChanges:list",
|
||||
"breakingChanges:migrate",
|
||||
"apiKey:manage",
|
||||
"apiKey:list",
|
||||
"apiKey:create",
|
||||
|
||||
@@ -77,7 +77,7 @@ export const RESOURCES = {
|
||||
mcpApiKey: ['create', 'rotate'] as const,
|
||||
chatHub: ['manage', 'message'] as const,
|
||||
chatHubAgent: [...DEFAULT_OPERATIONS] as const,
|
||||
breakingChanges: ['list'] as const,
|
||||
breakingChanges: ['list', 'migrate'] as const,
|
||||
apiKey: ['manage', 'list', 'create', 'delete', 'update'] as const,
|
||||
encryptionKey: ['manage'] as const,
|
||||
credentialResolver: [...DEFAULT_OPERATIONS] as const,
|
||||
|
||||
@@ -140,6 +140,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'chatHubAgent:delete',
|
||||
'chatHubAgent:list',
|
||||
'breakingChanges:list',
|
||||
'breakingChanges:migrate',
|
||||
'execution:reveal',
|
||||
'apiKey:manage',
|
||||
'apiKey:list',
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { NodeMigration } from '../migrations/node-migration';
|
||||
|
||||
// Control the migration list the registry loads so the tests don't depend on
|
||||
// which real migrations happen to be registered.
|
||||
const { migA, migB, migADuplicate } = vi.hoisted(() => ({
|
||||
migA: { ruleId: 'rule-a', migrate: vi.fn() },
|
||||
migB: { ruleId: 'rule-b', migrate: vi.fn() },
|
||||
// Same ruleId as migA, to exercise the duplicate-registration branch.
|
||||
migADuplicate: { ruleId: 'rule-a', migrate: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../migrations', () => ({ nodeMigrations: [migA, migB, migADuplicate] }));
|
||||
|
||||
import { MigrationRegistry } from '../breaking-changes.migration-registry.service';
|
||||
|
||||
describe('MigrationRegistry', () => {
|
||||
const scopedLogger = mock<Logger>();
|
||||
const logger = mock<Logger>({ scoped: vi.fn().mockReturnValue(scopedLogger) });
|
||||
let registry: MigrationRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registry = new MigrationRegistry(logger);
|
||||
});
|
||||
|
||||
it('registers migrations keyed by their ruleId', () => {
|
||||
registry.registerAll();
|
||||
|
||||
expect(registry.has('rule-b')).toBe(true);
|
||||
expect(registry.get('rule-b')).toBe(migB as unknown as NodeMigration);
|
||||
});
|
||||
|
||||
it('reports unknown rules as unregistered', () => {
|
||||
registry.registerAll();
|
||||
|
||||
expect(registry.has('unknown-rule')).toBe(false);
|
||||
expect(registry.get('unknown-rule')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns and keeps the last migration when a ruleId is registered twice', () => {
|
||||
registry.registerAll();
|
||||
|
||||
expect(scopedLogger.warn).toHaveBeenCalledWith(expect.stringContaining('rule-a'));
|
||||
expect(registry.get('rule-a')).toBe(migADuplicate as unknown as NodeMigration);
|
||||
});
|
||||
});
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import type { User, WorkflowEntity } from '@n8n/db';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import type { WorkflowValidationService } from '@/workflows/workflow-validation.service';
|
||||
import type { WorkflowService } from '@/workflows/workflow.service';
|
||||
|
||||
// Stub the heavy module so importing the service under test doesn't pull the
|
||||
// task-runner import chain; the service receives a typed mock instance anyway.
|
||||
vi.mock('@/workflows/workflow.service', () => ({ WorkflowService: vi.fn() }));
|
||||
|
||||
import { MigrationRegistry } from '../breaking-changes.migration-registry.service';
|
||||
import { BreakingChangeMigrationService } from '../breaking-changes.migration.service';
|
||||
import { RuleRegistry } from '../breaking-changes.rule-registry.service';
|
||||
import {
|
||||
AiTransformDeprecatedRule,
|
||||
AI_TRANSFORM_NODE_TYPE,
|
||||
} from '../rules/v3/ai-transform-deprecated.rule';
|
||||
import { createNode } from './test-helpers';
|
||||
|
||||
describe('BreakingChangeMigrationService', () => {
|
||||
const logger = mockLogger();
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
const RULE_ID = 'ai-transform-deprecated';
|
||||
|
||||
let ruleRegistry: RuleRegistry;
|
||||
let migrationRegistry: MigrationRegistry;
|
||||
let workflowFinderService: Mocked<WorkflowFinderService>;
|
||||
let workflowService: Mocked<WorkflowService>;
|
||||
let workflowValidationService: Mocked<WorkflowValidationService>;
|
||||
let nodeTypes: Mocked<NodeTypes>;
|
||||
let service: BreakingChangeMigrationService;
|
||||
|
||||
const buildWorkflow = (nodes: INode[], overrides: Partial<WorkflowEntity> = {}) =>
|
||||
mock<WorkflowEntity>({ id: 'wf-1', name: 'My WF', nodes, ...overrides });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
ruleRegistry = new RuleRegistry(logger);
|
||||
ruleRegistry.registerAll([new AiTransformDeprecatedRule()]);
|
||||
migrationRegistry = new MigrationRegistry(logger);
|
||||
migrationRegistry.registerAll();
|
||||
|
||||
workflowFinderService = mock<WorkflowFinderService>();
|
||||
workflowService = mock<WorkflowService>();
|
||||
workflowValidationService = mock<WorkflowValidationService>();
|
||||
nodeTypes = mock<NodeTypes>();
|
||||
|
||||
service = new BreakingChangeMigrationService(
|
||||
ruleRegistry,
|
||||
migrationRegistry,
|
||||
workflowFinderService,
|
||||
workflowService,
|
||||
workflowValidationService,
|
||||
nodeTypes,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when no migration is registered for the rule', async () => {
|
||||
await expect(service.migrateWorkflow('unknown-rule', 'wf-1', user)).rejects.toThrow(
|
||||
"No automated migration is available for rule 'unknown-rule'.",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the workflow is not accessible', async () => {
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(service.migrateWorkflow(RULE_ID, 'wf-1', user)).rejects.toThrow(
|
||||
'You do not have permission to update this workflow',
|
||||
);
|
||||
expect(workflowService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the workflow has no affected nodes', async () => {
|
||||
const workflow = buildWorkflow([createNode('Set', 'n8n-nodes-base.set', {})]);
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
|
||||
|
||||
await expect(service.migrateWorkflow(RULE_ID, 'wf-1', user)).rejects.toThrow(
|
||||
'no nodes affected',
|
||||
);
|
||||
expect(workflowService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts without saving when an affected node has no generated code', async () => {
|
||||
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
|
||||
instructions: 'x',
|
||||
jsCode: '',
|
||||
});
|
||||
const workflow = buildWorkflow([aiNode]);
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
|
||||
|
||||
const error = await service.migrateWorkflow(RULE_ID, 'wf-1', user).catch((e: unknown) => e);
|
||||
// Carries the offending node so the UI can link to it.
|
||||
expect(error).toMatchObject({
|
||||
message: expect.stringContaining('no generated code yet'),
|
||||
meta: { nodeId: aiNode.id, nodeName: 'Transform' },
|
||||
});
|
||||
expect(workflowService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the whole workflow (no save) when one of several affected nodes refuses', async () => {
|
||||
const okNode = createNode('Ok', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
|
||||
const badNode = createNode('Bad', AI_TRANSFORM_NODE_TYPE, { jsCode: '' });
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(buildWorkflow([okNode, badNode]));
|
||||
|
||||
const error = await service.migrateWorkflow(RULE_ID, 'wf-1', user).catch((e: unknown) => e);
|
||||
|
||||
expect(error).toMatchObject({ meta: { nodeId: badNode.id, nodeName: 'Bad' } });
|
||||
// The migratable node is not saved either — the migration is all-or-nothing.
|
||||
expect(workflowService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rewrites the affected node in place and saves a new version', async () => {
|
||||
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
|
||||
jsCode: 'return items;',
|
||||
instructions: 'x',
|
||||
});
|
||||
const otherNode = createNode('Set', 'n8n-nodes-base.set', { value: 1 });
|
||||
const workflow = buildWorkflow([aiNode, otherNode]);
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
|
||||
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
|
||||
|
||||
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
|
||||
|
||||
expect(result).toEqual({
|
||||
workflowId: 'wf-1',
|
||||
newVersionId: 'new-version',
|
||||
migratedNodeIds: [aiNode.id],
|
||||
unmapped: [],
|
||||
notes: [],
|
||||
// Not published on this version, so no one-click re-publish is offered.
|
||||
republishable: false,
|
||||
});
|
||||
|
||||
// A checksum of the fetched workflow is passed so a concurrent edit is
|
||||
// rejected as a conflict rather than silently overwritten.
|
||||
const [, updateData, , updateOptions] = workflowService.update.mock.calls[0];
|
||||
expect(updateOptions).toEqual(
|
||||
expect.objectContaining({ expectedChecksum: expect.any(String) }),
|
||||
);
|
||||
const migrated = updateData.nodes.find((n) => n.id === aiNode.id)!;
|
||||
// Identity preserved so connections (keyed by node name) stay intact.
|
||||
expect(migrated.id).toBe(aiNode.id);
|
||||
expect(migrated.name).toBe('Transform');
|
||||
expect(migrated.position).toEqual(aiNode.position);
|
||||
// Type/params rewritten to a Code node.
|
||||
expect(migrated.type).toBe('n8n-nodes-base.code');
|
||||
expect(migrated.typeVersion).toBe(2);
|
||||
expect(migrated.parameters).toEqual({
|
||||
mode: 'runOnceForAllItems',
|
||||
language: 'javaScript',
|
||||
jsCode: 'return items;',
|
||||
});
|
||||
// Untouched node stays as-is.
|
||||
expect(updateData.nodes.find((n) => n.id === otherNode.id)).toEqual(otherNode);
|
||||
});
|
||||
|
||||
it('marks a clean migration republishable when the published version was migrated and it validates', async () => {
|
||||
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
|
||||
// Published version == the version being migrated.
|
||||
const workflow = buildWorkflow([aiNode], { versionId: 'v1', activeVersionId: 'v1' });
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
|
||||
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
|
||||
workflowValidationService.validateForActivation.mockReturnValue({ isValid: true });
|
||||
|
||||
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
|
||||
|
||||
expect(result.republishable).toBe(true);
|
||||
// The MIGRATED node set (Code, not the original AI Transform) is what gets
|
||||
// validated, keyed by node name, with the workflow's connections and node types.
|
||||
expect(workflowValidationService.validateForActivation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Transform: expect.objectContaining({ type: 'n8n-nodes-base.code' }),
|
||||
}),
|
||||
workflow.connections,
|
||||
nodeTypes,
|
||||
);
|
||||
});
|
||||
|
||||
it('is not republishable when the migration reports warnings, even on the published version', async () => {
|
||||
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
|
||||
const workflow = buildWorkflow([aiNode], { versionId: 'v1', activeVersionId: 'v1' });
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
|
||||
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
|
||||
// A migration that carries a behavior-change note (not lossless).
|
||||
vi.spyOn(migrationRegistry, 'get').mockReturnValue({
|
||||
ruleId: RULE_ID,
|
||||
migrate: () => ({
|
||||
node: { type: 'n8n-nodes-base.code', typeVersion: 2, parameters: {} },
|
||||
notes: ['behavior changed'],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
|
||||
|
||||
expect(result.republishable).toBe(false);
|
||||
// A lossy migration short-circuits before the activation check.
|
||||
expect(workflowValidationService.validateForActivation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is not republishable when the migrated version was not the published one', async () => {
|
||||
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
|
||||
// A draft (v2) sits ahead of the published version (v1).
|
||||
const workflow = buildWorkflow([aiNode], { versionId: 'v2', activeVersionId: 'v1' });
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
|
||||
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
|
||||
workflowValidationService.validateForActivation.mockReturnValue({ isValid: true });
|
||||
|
||||
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
|
||||
|
||||
expect(result.republishable).toBe(false);
|
||||
// No point validating a workflow we won't offer to publish.
|
||||
expect(workflowValidationService.validateForActivation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is not republishable when the migrated workflow cannot be activated', async () => {
|
||||
const aiNode = createNode('Transform', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' });
|
||||
const workflow = buildWorkflow([aiNode], { versionId: 'v1', activeVersionId: 'v1' });
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValue(workflow);
|
||||
workflowService.update.mockResolvedValue(mock<WorkflowEntity>({ versionId: 'new-version' }));
|
||||
// e.g. only a manual trigger, so activation validation fails.
|
||||
workflowValidationService.validateForActivation.mockReturnValue({
|
||||
isValid: false,
|
||||
error: 'no trigger',
|
||||
});
|
||||
|
||||
const result = await service.migrateWorkflow(RULE_ID, 'wf-1', user);
|
||||
|
||||
expect(result.republishable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { mock } from 'vitest-mock-extended';
|
||||
import type { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import { N8N_VERSION } from '../../../constants';
|
||||
import { MigrationRegistry } from '../breaking-changes.migration-registry.service';
|
||||
import { RuleRegistry } from '../breaking-changes.rule-registry.service';
|
||||
import { BreakingChangeService } from '../breaking-changes.service';
|
||||
import { createNode, createWorkflow } from './test-helpers';
|
||||
@@ -46,6 +47,7 @@ describe('BreakingChangeService', () => {
|
||||
|
||||
service = new BreakingChangeService(
|
||||
ruleRegistry,
|
||||
new MigrationRegistry(logger),
|
||||
workflowRepository,
|
||||
workflowStatisticsRepository,
|
||||
cacheService,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { groupNodesByType } from '../group-nodes-by-type';
|
||||
import { createNode } from './test-helpers';
|
||||
|
||||
describe('groupNodesByType', () => {
|
||||
it('groups nodes by their type, preserving order within a type', () => {
|
||||
const set1 = createNode('Set1', 'n8n-nodes-base.set');
|
||||
const code = createNode('Code', 'n8n-nodes-base.code');
|
||||
const set2 = createNode('Set2', 'n8n-nodes-base.set');
|
||||
|
||||
const grouped = groupNodesByType([set1, code, set2]);
|
||||
|
||||
expect(grouped.size).toBe(2);
|
||||
expect(grouped.get('n8n-nodes-base.set')).toEqual([set1, set2]);
|
||||
expect(grouped.get('n8n-nodes-base.code')).toEqual([code]);
|
||||
});
|
||||
|
||||
it('returns an empty map for no nodes', () => {
|
||||
expect(groupNodesByType([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BreakingChangeReportQueryDto,
|
||||
BreakingChangeReportResult,
|
||||
BreakingChangeWorkflowRuleResult,
|
||||
WorkflowMigrationResult,
|
||||
} from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Get, RestController, GlobalScope, Query, Post, Param } from '@n8n/decorators';
|
||||
@@ -11,11 +12,15 @@ import { Response } from 'express';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { BreakingChangeMigrationService } from './breaking-changes.migration.service';
|
||||
import { BreakingChangeService } from './breaking-changes.service';
|
||||
|
||||
@RestController('/breaking-changes')
|
||||
export class BreakingChangesController {
|
||||
constructor(private readonly service: BreakingChangeService) {}
|
||||
constructor(
|
||||
private readonly service: BreakingChangeService,
|
||||
private readonly migrationService: BreakingChangeMigrationService,
|
||||
) {}
|
||||
|
||||
private getLightDetectionResults(
|
||||
report: BreakingChangeReportResult['report'],
|
||||
@@ -76,4 +81,19 @@ export class BreakingChangesController {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the rule's automated migration to a single workflow, saving the
|
||||
* rewritten workflow as a new version.
|
||||
*/
|
||||
@Post('/report/:ruleId/workflows/:workflowId/migrate')
|
||||
@GlobalScope('breakingChanges:migrate')
|
||||
async migrateWorkflow(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('ruleId') ruleId: string,
|
||||
@Param('workflowId') workflowId: string,
|
||||
): Promise<WorkflowMigrationResult> {
|
||||
return await this.migrationService.migrateWorkflow(ruleId, workflowId, req.user);
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { nodeMigrations } from './migrations';
|
||||
import type { NodeMigration } from './migrations/node-migration';
|
||||
|
||||
/**
|
||||
* Holds the node migrations, keyed by the breaking-change rule id that detects
|
||||
* the deprecated node. Detection (rules) and transformation (migrations) stay
|
||||
* decoupled: a rule can exist with no migration, in which case the report falls
|
||||
* back to prose recommendations.
|
||||
*/
|
||||
@Service()
|
||||
export class MigrationRegistry {
|
||||
private readonly migrations = new Map<string, NodeMigration>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger = logger.scoped('breaking-changes');
|
||||
}
|
||||
|
||||
registerAll(): void {
|
||||
for (const migration of nodeMigrations) {
|
||||
if (this.migrations.has(migration.ruleId)) {
|
||||
this.logger.warn(
|
||||
`Migration for rule ${migration.ruleId} is already registered. Overwriting.`,
|
||||
);
|
||||
}
|
||||
this.migrations.set(migration.ruleId, migration);
|
||||
}
|
||||
}
|
||||
|
||||
get(ruleId: string): NodeMigration | undefined {
|
||||
return this.migrations.get(ruleId);
|
||||
}
|
||||
|
||||
has(ruleId: string): boolean {
|
||||
return this.migrations.has(ruleId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { WorkflowMigrationResult } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { calculateWorkflowChecksum } from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import { WorkflowValidationService } from '@/workflows/workflow-validation.service';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
|
||||
import { MigrationRegistry } from './breaking-changes.migration-registry.service';
|
||||
import { RuleRegistry } from './breaking-changes.rule-registry.service';
|
||||
import { groupNodesByType } from './group-nodes-by-type';
|
||||
|
||||
/**
|
||||
* A migration refused a specific node. Carries the node identity in `meta` so the
|
||||
* UI can link straight to it.
|
||||
*/
|
||||
export class WorkflowMigrationNodeError extends BadRequestError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly meta: { nodeId: string; nodeName: string },
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'WorkflowMigrationNodeError';
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class BreakingChangeMigrationService {
|
||||
constructor(
|
||||
private readonly ruleRegistry: RuleRegistry,
|
||||
private readonly migrationRegistry: MigrationRegistry,
|
||||
private readonly workflowFinderService: WorkflowFinderService,
|
||||
private readonly workflowService: WorkflowService,
|
||||
private readonly workflowValidationService: WorkflowValidationService,
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.logger = logger.scoped('breaking-changes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a single workflow to swap the nodes a rule flags as deprecated for
|
||||
* their replacement, then saves the result as a new workflow version.
|
||||
*/
|
||||
async migrateWorkflow(
|
||||
ruleId: string,
|
||||
workflowId: string,
|
||||
user: User,
|
||||
): Promise<WorkflowMigrationResult> {
|
||||
const migration = this.migrationRegistry.get(ruleId);
|
||||
if (!migration) {
|
||||
throw new BadRequestError(`No automated migration is available for rule '${ruleId}'.`);
|
||||
}
|
||||
|
||||
const rule = this.ruleRegistry.getRule(ruleId);
|
||||
if (!rule || !('detectWorkflow' in rule)) {
|
||||
throw new BadRequestError(`Rule '${ruleId}' does not support automated migration.`);
|
||||
}
|
||||
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||
'workflow:update',
|
||||
]);
|
||||
if (!workflow) {
|
||||
throw new NotFoundError(
|
||||
'You do not have permission to update this workflow. Ask the owner to share it with you.',
|
||||
);
|
||||
}
|
||||
|
||||
// Whether the version we're about to migrate is the one currently published.
|
||||
// Only then may we offer a one-click re-publish: a draft ahead of the
|
||||
// published version must not be published without the user reviewing it.
|
||||
const wasPublishedVersion =
|
||||
!!workflow.activeVersionId && workflow.activeVersionId === workflow.versionId;
|
||||
|
||||
// Detection decides which nodes to migrate, so the transform stays aligned with the report.
|
||||
const detection = await rule.detectWorkflow(workflow, groupNodesByType(workflow.nodes));
|
||||
const affectedNodeIds = new Set(
|
||||
detection.issues.map((issue) => issue.nodeId).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
if (affectedNodeIds.size === 0) {
|
||||
throw new BadRequestError('This workflow has no nodes affected by the selected rule.');
|
||||
}
|
||||
|
||||
const unmapped: string[] = [];
|
||||
const notes: string[] = [];
|
||||
const migratedNodeIds: string[] = [];
|
||||
|
||||
const nodes = workflow.nodes.map((node) => {
|
||||
if (!affectedNodeIds.has(node.id)) return node;
|
||||
|
||||
// A node the migration refuses aborts the whole workflow before any save.
|
||||
let result;
|
||||
try {
|
||||
result = migration.migrate(node);
|
||||
} catch (error) {
|
||||
throw new WorkflowMigrationNodeError(
|
||||
error instanceof Error ? error.message : 'Migration failed.',
|
||||
{ nodeId: node.id, nodeName: node.name },
|
||||
);
|
||||
}
|
||||
migratedNodeIds.push(node.id);
|
||||
if (result.unmapped?.length) unmapped.push(...result.unmapped);
|
||||
if (result.notes?.length) notes.push(...result.notes);
|
||||
|
||||
// Keep id/name/position so connections (keyed by node name) stay intact.
|
||||
return {
|
||||
...node,
|
||||
type: result.node.type,
|
||||
typeVersion: result.node.typeVersion,
|
||||
parameters: result.node.parameters,
|
||||
} satisfies INode;
|
||||
});
|
||||
|
||||
// Best-effort hint for a one-click re-publish: clean migration of the published
|
||||
// version that still validates for activation. It's a subset of the full activation
|
||||
// gate, so publishing can still fail (handled gracefully in the UI).
|
||||
const republishable =
|
||||
unmapped.length === 0 &&
|
||||
notes.length === 0 &&
|
||||
wasPublishedVersion &&
|
||||
this.workflowValidationService.validateForActivation(
|
||||
Object.fromEntries(nodes.map((node) => [node.name, node])),
|
||||
workflow.connections,
|
||||
this.nodeTypes,
|
||||
).isValid;
|
||||
|
||||
// Checksum of the workflow as fetched, so a concurrent edit landing between
|
||||
// this read and the write below is rejected as a conflict rather than clobbered.
|
||||
const expectedChecksum = await calculateWorkflowChecksum(workflow);
|
||||
|
||||
workflow.nodes = nodes;
|
||||
const updated = await this.workflowService.update(user, workflow, workflowId, {
|
||||
versionName: 'Automated node migration',
|
||||
expectedChecksum,
|
||||
});
|
||||
|
||||
this.logger.info('Applied automated node migration', {
|
||||
ruleId,
|
||||
workflowId,
|
||||
migratedNodeIds,
|
||||
});
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
newVersionId: updated.versionId,
|
||||
migratedNodeIds,
|
||||
unmapped,
|
||||
notes,
|
||||
republishable,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export class BreakingChangesModule implements ModuleInterface {
|
||||
const { BreakingChangeService } = await import('./breaking-changes.service.js');
|
||||
Container.get(BreakingChangeService).registerRules();
|
||||
|
||||
// Register the node migrations keyed by rule id
|
||||
const { MigrationRegistry } = await import('./breaking-changes.migration-registry.service.js');
|
||||
Container.get(MigrationRegistry).registerAll();
|
||||
|
||||
await import('./breaking-changes.controller.js');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ import { BreakingChangeRuleMetadata } from '@n8n/decorators';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { In } from '@n8n/typeorm';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import { MigrationRegistry } from './breaking-changes.migration-registry.service';
|
||||
import { RuleRegistry } from './breaking-changes.rule-registry.service';
|
||||
import { groupNodesByType } from './group-nodes-by-type';
|
||||
import type {
|
||||
IBreakingChangeBatchWorkflowRule,
|
||||
IBreakingChangeInstanceRule,
|
||||
@@ -45,6 +46,7 @@ export class BreakingChangeService {
|
||||
|
||||
constructor(
|
||||
private readonly ruleRegistry: RuleRegistry,
|
||||
private readonly migrationRegistry: MigrationRegistry,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly workflowStatisticsRepository: WorkflowStatisticsRepository,
|
||||
private readonly cacheService: CacheService,
|
||||
@@ -78,6 +80,7 @@ export class BreakingChangeService {
|
||||
ruleDocumentationUrl: rule.getMetadata().documentationUrl,
|
||||
instanceIssues: ruleResult.instanceIssues,
|
||||
recommendations: ruleResult.recommendations,
|
||||
migratable: this.migrationRegistry.has(rule.id),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -88,17 +91,6 @@ export class BreakingChangeService {
|
||||
return instanceLevelResults;
|
||||
}
|
||||
|
||||
private groupNodesByType(nodes: INode[]): Map<string, INode[]> {
|
||||
const nodesGroupedByType: Map<string, INode[]> = new Map();
|
||||
for (const node of nodes) {
|
||||
if (!nodesGroupedByType.has(node.type)) {
|
||||
nodesGroupedByType.set(node.type, []);
|
||||
}
|
||||
nodesGroupedByType.get(node.type)!.push(node);
|
||||
}
|
||||
return nodesGroupedByType;
|
||||
}
|
||||
|
||||
private async aggregateRegularRuleResults(
|
||||
workflowLevelRules: IBreakingChangeWorkflowRule[],
|
||||
allAffectedWorkflowsByRule: Map<string, BreakingChangeAffectedWorkflow[]>,
|
||||
@@ -117,6 +109,7 @@ export class BreakingChangeService {
|
||||
ruleDocumentationUrl: rule.getMetadata().documentationUrl,
|
||||
affectedWorkflows: workflowResults,
|
||||
recommendations: await rule.getRecommendations(workflowResults),
|
||||
migratable: this.migrationRegistry.has(rule.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -165,6 +158,7 @@ export class BreakingChangeService {
|
||||
ruleDocumentationUrl: rule.getMetadata().documentationUrl,
|
||||
affectedWorkflows,
|
||||
recommendations: await rule.getRecommendations(affectedWorkflows),
|
||||
migratable: this.migrationRegistry.has(rule.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -218,7 +212,7 @@ export class BreakingChangeService {
|
||||
}
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const nodesGroupedByType = this.groupNodesByType(workflow.nodes);
|
||||
const nodesGroupedByType = groupNodesByType(workflow.nodes);
|
||||
const statistics = statisticsByWorkflowId.get(workflow.id) ?? [];
|
||||
|
||||
const workflowMetadata: WorkflowMetadata = {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
/** Group a workflow's nodes by their `type`, as the workflow rules expect. */
|
||||
export function groupNodesByType(nodes: INode[]): Map<string, INode[]> {
|
||||
const grouped = new Map<string, INode[]>();
|
||||
for (const node of nodes) {
|
||||
const existing = grouped.get(node.type);
|
||||
if (existing) existing.push(node);
|
||||
else grouped.set(node.type, [node]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { createNode } from '../../__tests__/test-helpers';
|
||||
import { AI_TRANSFORM_NODE_TYPE } from '../../rules/v3/ai-transform-deprecated.rule';
|
||||
import { aiTransformToCode } from '../ai-transform-to-code.migration';
|
||||
|
||||
describe('aiTransformToCode migration', () => {
|
||||
it('is keyed by the AI Transform rule id', () => {
|
||||
expect(aiTransformToCode.ruleId).toBe('ai-transform-deprecated');
|
||||
});
|
||||
|
||||
it('rewrites AI Transform to a Code node carrying jsCode verbatim', () => {
|
||||
const node = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
|
||||
jsCode: 'return [{ json: { ok: true } }];',
|
||||
instructions: 'do a thing',
|
||||
codeGeneratedForPrompt: 'do a thing',
|
||||
});
|
||||
|
||||
const result = aiTransformToCode.migrate(node);
|
||||
|
||||
expect(result.node).toEqual({
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
language: 'javaScript',
|
||||
jsCode: 'return [{ json: { ok: true } }];',
|
||||
},
|
||||
});
|
||||
// Lossless: nothing dropped, nothing to warn about.
|
||||
expect(result.unmapped).toBeUndefined();
|
||||
expect(result.notes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to migrate a node whose prompt was never turned into code', () => {
|
||||
// Prompt entered but "Generate code" never clicked → jsCode is empty.
|
||||
const node = createNode('Transform', AI_TRANSFORM_NODE_TYPE, {
|
||||
instructions: 'Double the value',
|
||||
jsCode: '',
|
||||
});
|
||||
|
||||
expect(() => aiTransformToCode.migrate(node)).toThrow('no generated code yet');
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import type { NodeMigration } from './node-migration';
|
||||
|
||||
/**
|
||||
* AI Transform → Code. Lossless when the node has generated code: both nodes run
|
||||
* the same JsTaskRunnerSandbox and store it under `jsCode`, and AI Transform always
|
||||
* runs once for all items. The AI-authoring-only params (`instructions`,
|
||||
* `codeGeneratedForPrompt`) are dropped because they have no runtime effect on the
|
||||
* Code node.
|
||||
*
|
||||
* A node whose prompt was never turned into code (`jsCode` empty) has nothing to
|
||||
* carry over and already errors at runtime, so we refuse rather than silently
|
||||
* produce an empty Code node and lose the prompt.
|
||||
*/
|
||||
export const aiTransformToCode: NodeMigration = {
|
||||
ruleId: 'ai-transform-deprecated',
|
||||
migrate: (node) => {
|
||||
const jsCode = node.parameters.jsCode;
|
||||
if (!jsCode) {
|
||||
throw new Error(
|
||||
'This AI Transform node has no generated code yet. Open it and click "Generate code" (or replace it with a Code node manually) before migrating.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
language: 'javaScript',
|
||||
jsCode,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { aiTransformToCode } from './ai-transform-to-code.migration';
|
||||
import type { NodeMigration } from './node-migration';
|
||||
|
||||
// All registered node migrations. A rule is auto-migratable only if it appears here.
|
||||
export const nodeMigrations: NodeMigration[] = [aiTransformToCode];
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
export interface NodeMigrationResult {
|
||||
// The replacement node's type/version/parameters. The rewrite engine keeps
|
||||
// the original node's id, name, and position, so connections are preserved.
|
||||
node: Pick<INode, 'type' | 'typeVersion' | 'parameters'>;
|
||||
// Parameters that could not be carried over (engine warns, keeps original).
|
||||
unmapped?: string[];
|
||||
// Behavior/output changes to surface to the user.
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-node transform that swaps a deprecated node for its replacement.
|
||||
* Keyed by the breaking-change rule id that detects the deprecated node.
|
||||
*/
|
||||
export interface NodeMigration {
|
||||
ruleId: string;
|
||||
// Pure, per node. Return the replacement node; throw to abort with an error.
|
||||
migrate(node: INode): NodeMigrationResult;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import './v2/wait-node-subworkflow.rule';
|
||||
import './v2/workflow-hooks-deprecated.rule';
|
||||
|
||||
// v3 rules
|
||||
import './v3/ai-transform-deprecated.rule';
|
||||
import './v3/always-output-data-multi-output.rule';
|
||||
import './v3/chat-trigger-embedded-json.rule';
|
||||
import './v3/compression-node-limits.rule';
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
|
||||
import { AI_TRANSFORM_NODE_TYPE, AiTransformDeprecatedRule } from '../ai-transform-deprecated.rule';
|
||||
|
||||
describe('AiTransformDeprecatedRule', () => {
|
||||
let rule: AiTransformDeprecatedRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new AiTransformDeprecatedRule();
|
||||
});
|
||||
|
||||
describe('detectWorkflow()', () => {
|
||||
it('should not be affected when no AI Transform node is present', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Code', 'n8n-nodes-base.code', { jsCode: 'return items;' }),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect each AI Transform node', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Transform A', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return items;' }),
|
||||
createNode('Transform B', AI_TRANSFORM_NODE_TYPE, { jsCode: 'return [];' }),
|
||||
createNode('Set', 'n8n-nodes-base.set', {}),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(2);
|
||||
expect(result.issues.map((i) => i.nodeName)).toEqual(['Transform A', 'Transform B']);
|
||||
expect(result.issues[0].level).toBe('warning');
|
||||
expect(result.issues[0].nodeId).toBe('node-Transform A');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeWorkflowRule,
|
||||
WorkflowDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class AiTransformDeprecatedRule implements IBreakingChangeWorkflowRule {
|
||||
id: string = 'ai-transform-deprecated';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'AI Transform node is deprecated',
|
||||
description:
|
||||
'The AI Transform node is deprecated. Its generated code runs as a Code node instead.',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Replace AI Transform with a Code node',
|
||||
description:
|
||||
'The AI Transform node runs its generated JavaScript in the same sandbox as the Code node. Migrate it to a Code node to keep it working.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async detectWorkflow(
|
||||
_workflow: WorkflowEntity,
|
||||
nodesGroupedByType: Map<string, INode[]>,
|
||||
): Promise<WorkflowDetectionReport> {
|
||||
const affectedNodes = nodesGroupedByType.get(AI_TRANSFORM_NODE_TYPE) ?? [];
|
||||
if (affectedNodes.length === 0) return { isAffected: false, issues: [] };
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
issues: affectedNodes.map((node) => ({
|
||||
title: `Node '${node.name}' uses the deprecated AI Transform node`,
|
||||
description:
|
||||
'The AI Transform node is deprecated. Migrate it to a Code node to keep it working.',
|
||||
level: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4063,6 +4063,23 @@
|
||||
"settings.migrationReport.detail.filter.status.all": "All",
|
||||
"settings.migrationReport.detail.filter.status.active": "Active",
|
||||
"settings.migrationReport.detail.filter.status.deactivated": "Deactivated",
|
||||
"settings.migrationReport.detail.migrate.button": "Migrate",
|
||||
"settings.migrationReport.detail.migrate.migrated": "Migrated",
|
||||
"settings.migrationReport.detail.migrate.error.title": "Could not migrate workflow",
|
||||
"settings.migrationReport.detail.migrate.modal.title": "Migrate \"{name}\"",
|
||||
"settings.migrationReport.detail.migrate.modal.confirmLead": "What will change:",
|
||||
"settings.migrationReport.detail.migrate.modal.stepMigrate": "Migrate saves the changes as a new version. Your published version keeps running unchanged.",
|
||||
"settings.migrationReport.detail.migrate.modal.stepPublish": "Publish makes the migrated version live — a separate step, after you migrate.",
|
||||
"settings.migrationReport.detail.migrate.modal.confirmButton": "Migrate",
|
||||
"settings.migrationReport.detail.migrate.modal.successBody": "\"{name}\" was migrated and saved as a new version.",
|
||||
"settings.migrationReport.detail.migrate.modal.openWorkflow": "Open workflow",
|
||||
"settings.migrationReport.detail.migrate.modal.reviewTitle": "Review these changes",
|
||||
"settings.migrationReport.detail.migrate.modal.reviewNodes": "Affected nodes:",
|
||||
"settings.migrationReport.detail.migrate.modal.done": "Done",
|
||||
"settings.migrationReport.detail.migrate.publish.button": "Publish",
|
||||
"settings.migrationReport.detail.migrate.publish.skip": "Skip publishing",
|
||||
"settings.migrationReport.detail.migrate.publish.published": "Published to the migrated version",
|
||||
"settings.migrationReport.detail.migrate.publish.error.title": "Could not publish workflow",
|
||||
"showMessage.cancel": "@:_reusableBaseText.cancel",
|
||||
"showMessage.ok": "OK",
|
||||
"showMessage.showDetails": "Show Details",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BreakingChangeLightReportResult,
|
||||
BreakingChangeWorkflowRuleResult,
|
||||
BreakingChangeVersion,
|
||||
WorkflowMigrationResult,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
@@ -35,3 +36,15 @@ export async function getReportForRule(
|
||||
): Promise<BreakingChangeWorkflowRuleResult> {
|
||||
return (await get(context.baseUrl, `/breaking-changes/report/${ruleId}`)).data;
|
||||
}
|
||||
|
||||
export async function migrateWorkflowForRule(
|
||||
context: IRestApiContext,
|
||||
ruleId: string,
|
||||
workflowId: string,
|
||||
): Promise<WorkflowMigrationResult> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
`/breaking-changes/report/${ruleId}/workflows/${workflowId}/migrate`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
BINARY_DATA_VIEW_MODAL_KEY,
|
||||
STOP_MANY_EXECUTIONS_MODAL_KEY,
|
||||
ADD_EXECUTION_TO_DATASET_MODAL_KEY,
|
||||
MIGRATE_WORKFLOW_MODAL_KEY,
|
||||
WORKFLOW_DESCRIPTION_MODAL_KEY,
|
||||
WORKFLOW_PUBLISH_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
|
||||
@@ -135,6 +136,7 @@ import StopManyExecutionsModal from './StopManyExecutionsModal.vue';
|
||||
import AddExecutionToDatasetModal from '@/features/ai/evaluation.ee/components/AddExecutionToDataset/AddExecutionToDatasetModal.vue';
|
||||
import WorkflowDescriptionModal from '@/app/components/WorkflowDescriptionModal.vue';
|
||||
import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishModal.vue';
|
||||
import MigrateWorkflowModal from '@/features/settings/migrationReport/MigrateWorkflowModal.vue';
|
||||
import UpdatesPanel from './UpdatesPanel.vue';
|
||||
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
|
||||
import AIBuilderDiffModal from '@/features/ai/assistant/components/Agent/AIBuilderDiffModal.vue';
|
||||
@@ -516,6 +518,12 @@ import InstanceAiToolsConnectionModalWrapper from '@/features/ai/instanceAi/comp
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="MIGRATE_WORKFLOW_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<MigrateWorkflowModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="WORKFLOW_HISTORY_PUBLISH_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<WorkflowVersionFormModal
|
||||
|
||||
@@ -49,3 +49,4 @@ export const INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY = 'instanceAiBrowserUseSetu
|
||||
export const INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY = 'instanceAiToolsConnection';
|
||||
export const AGENT_CONFIRMATION_MODAL_KEY = 'agentConfirmation';
|
||||
export const ADD_EXECUTION_TO_DATASET_MODAL_KEY = 'addExecutionToDataset';
|
||||
export const MIGRATE_WORKFLOW_MODAL_KEY = 'migrateWorkflow';
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
AGENT_CONFIRMATION_MODAL_KEY,
|
||||
ADD_EXECUTION_TO_DATASET_MODAL_KEY,
|
||||
MIGRATE_WORKFLOW_MODAL_KEY,
|
||||
} from '@/app/constants';
|
||||
import {
|
||||
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||
@@ -177,6 +178,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||
INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY,
|
||||
AI_GATEWAY_TOP_UP_MODAL_KEY,
|
||||
AGENT_CONFIRMATION_MODAL_KEY,
|
||||
MIGRATE_WORKFLOW_MODAL_KEY,
|
||||
].map((modalKey) => [modalKey, { open: false }]),
|
||||
),
|
||||
[DELETE_USER_MODAL_KEY]: {
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { MIGRATE_WORKFLOW_MODAL_KEY } from '@/app/constants';
|
||||
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
|
||||
import MigrateWorkflowModal from './MigrateWorkflowModal.vue';
|
||||
|
||||
vi.mock('@n8n/rest-api-client/api/breaking-changes', () => ({
|
||||
migrateWorkflowForRule: vi.fn(),
|
||||
}));
|
||||
|
||||
const workflow = {
|
||||
id: 'workflow-1',
|
||||
name: 'Test Workflow',
|
||||
active: false,
|
||||
numberOfExecutions: 0,
|
||||
lastUpdatedAt: new Date('2024-01-15'),
|
||||
issues: [
|
||||
{
|
||||
nodeId: 'node-1',
|
||||
nodeName: 'AI Transform',
|
||||
title: 'Deprecated',
|
||||
description: '',
|
||||
level: 'error' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const migrationResult = {
|
||||
workflowId: 'workflow-1',
|
||||
newVersionId: 'new-version-1234',
|
||||
migratedNodeIds: ['node-1'],
|
||||
unmapped: [],
|
||||
notes: [],
|
||||
republishable: false,
|
||||
};
|
||||
|
||||
const recommendations = [
|
||||
{ action: 'Replace AI Transform with a Code node', description: 'Runs in the same sandbox.' },
|
||||
];
|
||||
|
||||
let rootStore: ReturnType<typeof mockedStore<typeof useRootStore>>;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let eventBus: EventBus;
|
||||
|
||||
const renderComponent = createComponentRenderer(MigrateWorkflowModal, {
|
||||
pinia: createTestingPinia(),
|
||||
global: {
|
||||
stubs: {
|
||||
// Render all slots directly so the modal body/footer are queryable
|
||||
// without the open-state + teleport machinery of the real Modal.
|
||||
Modal: {
|
||||
template:
|
||||
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function render() {
|
||||
eventBus = createEventBus();
|
||||
return renderComponent({
|
||||
props: {
|
||||
modalName: MIGRATE_WORKFLOW_MODAL_KEY,
|
||||
data: { ruleId: 'rule-1', workflow, recommendations, eventBus },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('MigrateWorkflowModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rootStore = mockedStore(useRootStore);
|
||||
rootStore.restApiContext = { baseUrl: 'http://localhost:5678', pushRef: 'test' };
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockResolvedValue(migrationResult);
|
||||
});
|
||||
|
||||
it('closes without migrating when cancelled', async () => {
|
||||
const onClose = vi.fn();
|
||||
render();
|
||||
eventBus.on('close', onClose);
|
||||
|
||||
await userEvent.click(screen.getByTestId('migrate-modal-cancel-button'));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(breakingChangesApi.migrateWorkflowForRule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the concrete change (rule recommendation) before confirming', () => {
|
||||
render();
|
||||
|
||||
expect(screen.getByText('Replace AI Transform with a Code node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Runs in the same sandbox.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('migrates on confirm, shows success and reports back on the bus', async () => {
|
||||
const onMigrated = vi.fn();
|
||||
render();
|
||||
eventBus.on('migrated', onMigrated);
|
||||
|
||||
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(breakingChangesApi.migrateWorkflowForRule).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'rule-1',
|
||||
'workflow-1',
|
||||
),
|
||||
);
|
||||
expect(onMigrated).toHaveBeenCalledWith({ workflowId: 'workflow-1' });
|
||||
// Success state: confirmation copy, an Open workflow link (resolving to a real
|
||||
// href, not "[object Object]"), and a Done action.
|
||||
expect(await screen.findByText(/was migrated and saved as a new version/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Open workflow').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/workflow/workflow-1',
|
||||
);
|
||||
expect(screen.getByTestId('migrate-modal-done-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers Publish for a republishable migration and publishes on click', async () => {
|
||||
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockResolvedValue({
|
||||
...migrationResult,
|
||||
republishable: true,
|
||||
});
|
||||
render();
|
||||
|
||||
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
|
||||
// Both CTAs make the two-step (migrate → publish) explicit.
|
||||
expect(await screen.findByTestId('migrate-modal-skip-publish-button')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('migrate-modal-publish-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(workflowsStore.publishWorkflow).toHaveBeenCalledWith('workflow-1', {
|
||||
versionId: 'new-version-1234',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer Publish when the migration is not republishable', async () => {
|
||||
render();
|
||||
|
||||
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
|
||||
|
||||
await screen.findByTestId('migrate-modal-done-button');
|
||||
expect(screen.queryByTestId('migrate-modal-publish-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces migration warnings for review', async () => {
|
||||
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockResolvedValue({
|
||||
...migrationResult,
|
||||
notes: ['Include Binary File was carried over — review it manually.'],
|
||||
});
|
||||
render();
|
||||
|
||||
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Include Binary File was carried over — review it manually.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error (with a node link) when the migration is refused', async () => {
|
||||
vi.mocked(breakingChangesApi.migrateWorkflowForRule).mockRejectedValue(
|
||||
new ResponseError('This node has no generated code yet.', {
|
||||
httpStatusCode: 400,
|
||||
meta: { nodeId: 'node-1', nodeName: 'AI Transform' },
|
||||
}),
|
||||
);
|
||||
render();
|
||||
|
||||
await userEvent.click(screen.getByTestId('migrate-modal-confirm-button'));
|
||||
|
||||
expect(await screen.findByText('This node has no generated code yet.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('migrate-modal-close-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
<script lang="ts" setup>
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import { MIGRATE_WORKFLOW_MODAL_KEY } from '@/app/constants';
|
||||
import type {
|
||||
BreakingChangeRecommendation,
|
||||
BreakingChangeWorkflowRuleResult,
|
||||
WorkflowMigrationResult,
|
||||
} from '@n8n/api-types';
|
||||
import { N8nButton, N8nCallout, N8nHeading, N8nLink, N8nText } from '@n8n/design-system';
|
||||
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
type AffectedWorkflow = BreakingChangeWorkflowRuleResult['affectedWorkflows'][number];
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
ruleId: string;
|
||||
workflow: AffectedWorkflow;
|
||||
// The rule's recommendations, shown as the concrete "what will change" detail.
|
||||
recommendations: BreakingChangeRecommendation[];
|
||||
// Emitted back to the report so the row can reflect the migrated state.
|
||||
eventBus: EventBus;
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const workflow = computed(() => props.data.workflow);
|
||||
|
||||
type Phase = 'confirm' | 'success' | 'error';
|
||||
const phase = ref<Phase>('confirm');
|
||||
const migrating = ref(false);
|
||||
const result = ref<WorkflowMigrationResult | null>(null);
|
||||
// The failure message, plus the node a migration refused (so the error state can link to it).
|
||||
const error = ref<{ message: string; node?: { id: string; name: string } } | null>(null);
|
||||
const publishing = ref(false);
|
||||
const published = ref(false);
|
||||
|
||||
// Names of the migrated nodes, resolved from the report's issues, for review links.
|
||||
const migratedNodes = computed(() =>
|
||||
(result.value?.migratedNodeIds ?? []).map((id) => ({
|
||||
id,
|
||||
name: workflow.value.issues.find((issue) => issue.nodeId === id)?.nodeName ?? id,
|
||||
})),
|
||||
);
|
||||
|
||||
const warnings = computed(() => [
|
||||
...(result.value?.notes ?? []),
|
||||
...(result.value?.unmapped ?? []),
|
||||
]);
|
||||
|
||||
async function handleMigrate() {
|
||||
migrating.value = true;
|
||||
try {
|
||||
result.value = await breakingChangesApi.migrateWorkflowForRule(
|
||||
useRootStore().restApiContext,
|
||||
props.data.ruleId,
|
||||
workflow.value.id,
|
||||
);
|
||||
props.data.eventBus.emit('migrated', { workflowId: workflow.value.id });
|
||||
phase.value = 'success';
|
||||
} catch (e) {
|
||||
// A migration that refuses a specific node tells us which one — surface it so
|
||||
// the user can jump straight there.
|
||||
const meta = e instanceof ResponseError ? e.meta : undefined;
|
||||
error.value = {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
node:
|
||||
typeof meta?.nodeId === 'string'
|
||||
? {
|
||||
id: meta.nodeId,
|
||||
name: typeof meta.nodeName === 'string' ? meta.nodeName : meta.nodeId,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
phase.value = 'error';
|
||||
} finally {
|
||||
migrating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!result.value) return;
|
||||
publishing.value = true;
|
||||
try {
|
||||
await workflowsStore.publishWorkflow(workflow.value.id, {
|
||||
versionId: result.value.newVersionId,
|
||||
});
|
||||
published.value = true;
|
||||
} catch (e) {
|
||||
toast.showError(
|
||||
e,
|
||||
i18n.baseText('settings.migrationReport.detail.migrate.publish.error.title'),
|
||||
);
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
props.data.eventBus.emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="MIGRATE_WORKFLOW_MODAL_KEY"
|
||||
:event-bus="data.eventBus"
|
||||
:center="true"
|
||||
:close-on-click-modal="false"
|
||||
width="540px"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading size="xlarge">
|
||||
{{
|
||||
i18n.baseText('settings.migrationReport.detail.migrate.modal.title', {
|
||||
interpolate: { name: workflow.name },
|
||||
})
|
||||
}}
|
||||
</N8nHeading>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content">
|
||||
<!-- Confirmation -->
|
||||
<template v-if="phase === 'confirm'">
|
||||
<div v-if="data.recommendations.length" :class="$style.section">
|
||||
<N8nText color="text-dark">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.confirmLead') }}
|
||||
</N8nText>
|
||||
<ul :class="$style.changeList">
|
||||
<li v-for="(rec, index) in data.recommendations" :key="index" :class="$style.change">
|
||||
<N8nText size="small" color="text-dark">{{ rec.action }}</N8nText>
|
||||
<N8nText size="small" color="text-base">{{ rec.description }}</N8nText>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Migrate and publish are two separate steps; make that explicit so users
|
||||
don't assume migrating changes the live version. -->
|
||||
<N8nCallout theme="info">
|
||||
<div>
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.stepMigrate') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.stepPublish') }}
|
||||
</div>
|
||||
</N8nCallout>
|
||||
</template>
|
||||
|
||||
<!-- Success -->
|
||||
<template v-else-if="phase === 'success' && result">
|
||||
<N8nCallout theme="success" icon="circle-check">
|
||||
{{
|
||||
i18n.baseText('settings.migrationReport.detail.migrate.modal.successBody', {
|
||||
interpolate: { name: workflow.name },
|
||||
})
|
||||
}}
|
||||
<N8nLink :to="`/workflow/${workflow.id}`" new-window size="small">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.openWorkflow') }}
|
||||
</N8nLink>
|
||||
</N8nCallout>
|
||||
|
||||
<!-- Behaviour changes the user should review -->
|
||||
<N8nCallout v-if="warnings.length" theme="warning">
|
||||
<div :class="$style.reviewTitle">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.reviewTitle') }}
|
||||
</div>
|
||||
<ul :class="$style.warningList">
|
||||
<li v-for="(warning, index) in warnings" :key="index">{{ warning }}</li>
|
||||
</ul>
|
||||
<div :class="$style.reviewNodes">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.modal.reviewNodes') }}
|
||||
<template v-for="(node, index) in migratedNodes" :key="node.id">
|
||||
<N8nLink :to="`/workflow/${workflow.id}/${node.id}`" new-window size="small">{{
|
||||
node.name
|
||||
}}</N8nLink
|
||||
><template v-if="index < migratedNodes.length - 1">, </template>
|
||||
</template>
|
||||
</div>
|
||||
</N8nCallout>
|
||||
|
||||
<N8nText v-if="published" size="small" color="success">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.publish.published') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<template v-else-if="phase === 'error' && error">
|
||||
<N8nCallout theme="danger" icon="status-error">
|
||||
{{ error.message }}
|
||||
<template v-if="error.node">
|
||||
<br />
|
||||
<N8nLink :to="`/workflow/${workflow.id}/${error.node.id}`" new-window size="small">{{
|
||||
error.node.name
|
||||
}}</N8nLink>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.actions">
|
||||
<template v-if="phase === 'confirm'">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
:disabled="migrating"
|
||||
:label="i18n.baseText('generic.cancel')"
|
||||
data-test-id="migrate-modal-cancel-button"
|
||||
@click="close"
|
||||
/>
|
||||
<N8nButton
|
||||
:loading="migrating"
|
||||
:label="i18n.baseText('settings.migrationReport.detail.migrate.modal.confirmButton')"
|
||||
data-test-id="migrate-modal-confirm-button"
|
||||
@click="handleMigrate"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="phase === 'success'">
|
||||
<template v-if="result?.republishable && !published">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
:disabled="publishing"
|
||||
:label="i18n.baseText('settings.migrationReport.detail.migrate.publish.skip')"
|
||||
data-test-id="migrate-modal-skip-publish-button"
|
||||
@click="close"
|
||||
/>
|
||||
<N8nButton
|
||||
:loading="publishing"
|
||||
:label="i18n.baseText('settings.migrationReport.detail.migrate.publish.button')"
|
||||
data-test-id="migrate-modal-publish-button"
|
||||
@click="handlePublish"
|
||||
/>
|
||||
</template>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="subtle"
|
||||
:label="i18n.baseText('settings.migrationReport.detail.migrate.modal.done')"
|
||||
data-test-id="migrate-modal-done-button"
|
||||
@click="close"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
:label="i18n.baseText('generic.close')"
|
||||
data-test-id="migrate-modal-close-button"
|
||||
@click="close"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.changeList {
|
||||
list-style-type: disc;
|
||||
padding-left: var(--spacing--sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.change {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reviewTitle {
|
||||
font-weight: var(--font-weight--bold);
|
||||
margin-bottom: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.warningList {
|
||||
list-style-type: disc;
|
||||
padding-left: var(--spacing--sm);
|
||||
margin-bottom: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.reviewNodes {
|
||||
margin-top: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
+56
@@ -2,18 +2,23 @@ import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { MIGRATE_WORKFLOW_MODAL_KEY } from '@/app/constants';
|
||||
import MigrationRuleDetail from './MigrationRuleDetail.vue';
|
||||
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
|
||||
import type { BreakingChangeWorkflowRuleResult } from '@n8n/api-types';
|
||||
|
||||
vi.mock('@n8n/rest-api-client/api/breaking-changes', () => ({
|
||||
getReportForRule: vi.fn(),
|
||||
migrateWorkflowForRule: vi.fn(),
|
||||
}));
|
||||
|
||||
let rootStore: ReturnType<typeof mockedStore<typeof useRootStore>>;
|
||||
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
|
||||
const mockWorkflowWithIssue = {
|
||||
@@ -70,6 +75,7 @@ const mockRuleResult: BreakingChangeWorkflowRuleResult = {
|
||||
description: 'Please update to the latest version',
|
||||
},
|
||||
],
|
||||
migratable: false,
|
||||
affectedWorkflows: [mockWorkflowWithIssue, mockWorkflowWithMultipleNodes],
|
||||
};
|
||||
|
||||
@@ -83,6 +89,7 @@ const createMockRuleResult = (
|
||||
ruleSeverity: 'critical',
|
||||
ruleDocumentationUrl: 'https://docs.example.com/rule-1',
|
||||
recommendations: [],
|
||||
migratable: false,
|
||||
affectedWorkflows: [],
|
||||
...overrides,
|
||||
};
|
||||
@@ -99,6 +106,7 @@ describe('MigrationRuleDetail', () => {
|
||||
baseUrl: 'http://localhost:5678',
|
||||
pushRef: 'test-push-ref',
|
||||
};
|
||||
uiStore = mockedStore(useUIStore);
|
||||
|
||||
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(mockRuleResult);
|
||||
});
|
||||
@@ -164,6 +172,54 @@ describe('MigrationRuleDetail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('should not render a Migrate button when the rule is not migratable', async () => {
|
||||
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(mockRuleResult);
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Rule')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('migrate-workflow-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the migrate modal with the rule and workflow when Migrate is clicked', async () => {
|
||||
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(
|
||||
createMockRuleResult({ migratable: true, affectedWorkflows: [mockWorkflowWithIssue] }),
|
||||
);
|
||||
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
await userEvent.click(await screen.findByTestId('migrate-workflow-button'));
|
||||
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: MIGRATE_WORKFLOW_MODAL_KEY,
|
||||
data: expect.objectContaining({
|
||||
ruleId: 'rule-1',
|
||||
workflow: expect.objectContaining({ id: mockWorkflowWithIssue.id }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Nothing is migrated yet — the modal drives that.
|
||||
expect(screen.getByTestId('migrate-workflow-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the row migrated when the modal reports a successful migration', async () => {
|
||||
vi.mocked(breakingChangesApi.getReportForRule).mockResolvedValue(
|
||||
createMockRuleResult({ migratable: true, affectedWorkflows: [mockWorkflowWithIssue] }),
|
||||
);
|
||||
|
||||
renderComponent({ props: { migrationRuleId: 'rule-1' } });
|
||||
await userEvent.click(await screen.findByTestId('migrate-workflow-button'));
|
||||
|
||||
// Emit on the same bus the detail passed into the modal.
|
||||
const { data } = vi.mocked(uiStore.openModalWithData).mock.calls[0][0];
|
||||
(data.eventBus as EventBus).emit('migrated', { workflowId: mockWorkflowWithIssue.id });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('migrate-workflow-button')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data table', () => {
|
||||
it('should display affected workflows in table', async () => {
|
||||
renderComponent({
|
||||
|
||||
+91
-36
@@ -2,10 +2,12 @@
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
import ResourceFiltersDropdown from '@/app/components/forms/ResourceFiltersDropdown.vue';
|
||||
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
||||
import { DEBOUNCE_TIME, VIEWS } from '@/app/constants';
|
||||
import { DEBOUNCE_TIME, MIGRATE_WORKFLOW_MODAL_KEY, VIEWS } from '@/app/constants';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import type { BreakingChangeWorkflowRuleResult } from '@n8n/api-types';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDataTableServer,
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
@@ -22,6 +24,7 @@ import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServ
|
||||
import * as breakingChangesApi from '@n8n/rest-api-client/api/breaking-changes';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useAsyncState, useDebounceFn } from '@vueuse/core';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { computed, ref } from 'vue';
|
||||
@@ -29,6 +32,7 @@ import { useRouter } from 'vue-router';
|
||||
import SeverityTag from './components/SeverityTag.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
useDocumentTitle().set(i18n.baseText('settings.migrationReport'));
|
||||
|
||||
@@ -52,46 +56,83 @@ const { state, isLoading } = useAsyncState(
|
||||
ruleSeverity: 'low',
|
||||
affectedWorkflows: [],
|
||||
recommendations: [],
|
||||
migratable: false,
|
||||
},
|
||||
);
|
||||
|
||||
type AffectedWorkflow = BreakingChangeWorkflowRuleResult['affectedWorkflows'][number];
|
||||
|
||||
const tableHeaders = ref<Array<TableHeader<AffectedWorkflow>>>([
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.name'),
|
||||
key: 'name',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.status'),
|
||||
key: 'active',
|
||||
value: (row: AffectedWorkflow) =>
|
||||
row.active
|
||||
? i18n.baseText('settings.migrationReport.detail.table.active')
|
||||
: i18n.baseText('settings.migrationReport.detail.table.deactivated'),
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.nodesAffected'),
|
||||
key: 'issues',
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.numberOfExecutions'),
|
||||
key: 'numberOfExecutions',
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastExecuted'),
|
||||
key: 'lastExecutedAt',
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastUpdated'),
|
||||
key: 'lastUpdatedAt',
|
||||
width: 40,
|
||||
},
|
||||
]);
|
||||
const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
|
||||
const headers: Array<TableHeader<AffectedWorkflow>> = [
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.name'),
|
||||
key: 'name',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.status'),
|
||||
key: 'active',
|
||||
value: (row: AffectedWorkflow) =>
|
||||
row.active
|
||||
? i18n.baseText('settings.migrationReport.detail.table.active')
|
||||
: i18n.baseText('settings.migrationReport.detail.table.deactivated'),
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.nodesAffected'),
|
||||
key: 'issues',
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.numberOfExecutions'),
|
||||
key: 'numberOfExecutions',
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastExecuted'),
|
||||
key: 'lastExecutedAt',
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastUpdated'),
|
||||
key: 'lastUpdatedAt',
|
||||
width: 40,
|
||||
},
|
||||
];
|
||||
|
||||
if (state.value.migratable) {
|
||||
headers.push({
|
||||
title: '',
|
||||
key: 'actions',
|
||||
value: () => '',
|
||||
width: 40,
|
||||
disableSort: true,
|
||||
});
|
||||
}
|
||||
|
||||
return headers;
|
||||
});
|
||||
|
||||
// Workflows successfully migrated this session (the row shows a "Migrated" state).
|
||||
const migratedWorkflowIds = ref<Set<string>>(new Set());
|
||||
|
||||
// The modal runs the migration (confirm → progress → result) and emits back when a
|
||||
// workflow was migrated so the row can reflect it.
|
||||
const migrateModalBus = createEventBus();
|
||||
migrateModalBus.on('migrated', ({ workflowId }: { workflowId: string }) => {
|
||||
migratedWorkflowIds.value = new Set(migratedWorkflowIds.value).add(workflowId);
|
||||
});
|
||||
|
||||
function openMigrateModal(workflow: AffectedWorkflow) {
|
||||
uiStore.openModalWithData({
|
||||
name: MIGRATE_WORKFLOW_MODAL_KEY,
|
||||
data: {
|
||||
ruleId: props.migrationRuleId,
|
||||
workflow,
|
||||
recommendations: state.value.recommendations,
|
||||
eventBus: migrateModalBus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleRowClick(_event: MouseEvent, { item }: { item: AffectedWorkflow }) {
|
||||
window.open(
|
||||
@@ -292,6 +333,7 @@ const sortedWorkflows = computed(() => {
|
||||
</div>
|
||||
|
||||
<N8nDataTableServer
|
||||
:key="String(state.migratable)"
|
||||
v-model:sort-by="sortBy"
|
||||
:items-per-page="sortedWorkflows.length + 1"
|
||||
:items="sortedWorkflows"
|
||||
@@ -323,6 +365,19 @@ const sortedWorkflows = computed(() => {
|
||||
<template #[`item.lastUpdatedAt`]="{ item }">
|
||||
<TimeAgo :date="item.lastUpdatedAt.toString()" />
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<N8nText v-if="migratedWorkflowIds.has(item.id)" color="text-light" size="small">
|
||||
{{ i18n.baseText('settings.migrationReport.detail.migrate.migrated') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
v-else
|
||||
size="small"
|
||||
type="secondary"
|
||||
:label="i18n.baseText('settings.migrationReport.detail.migrate.button')"
|
||||
data-test-id="migrate-workflow-button"
|
||||
@click.stop="openMigrateModal(item)"
|
||||
/>
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
</N8nSettingsLayout>
|
||||
</template>
|
||||
|
||||
+2
@@ -29,6 +29,7 @@ const mockWorkflowIssue = {
|
||||
description: 'Please update to the latest version',
|
||||
},
|
||||
],
|
||||
migratable: false,
|
||||
nbAffectedWorkflows: 5,
|
||||
};
|
||||
|
||||
@@ -44,6 +45,7 @@ const mockInstanceIssue = {
|
||||
description: 'Update your instance configuration',
|
||||
},
|
||||
],
|
||||
migratable: false,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Configuration issue',
|
||||
|
||||
+1
-136
@@ -1,141 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`viewsData > AIView > should return ai view with ai transform node 1`] = `
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"key": "ai_templates_root",
|
||||
"properties": {
|
||||
"description": "See what's possible and get started 5x faster",
|
||||
"icon": "box-open",
|
||||
"key": "ai_templates_root",
|
||||
"tag": {
|
||||
"text": "Recommended",
|
||||
"type": "info",
|
||||
},
|
||||
"title": "AI Templates",
|
||||
"url": "template-repository-url.n8n.io?test=value&utm_user_role=AdvancedAI",
|
||||
},
|
||||
"type": "link",
|
||||
"uuid": "ai_templates_root",
|
||||
},
|
||||
{
|
||||
"key": "agent",
|
||||
"properties": {
|
||||
"description": "example mock agent node",
|
||||
"displayName": "agent",
|
||||
"group": [],
|
||||
"icon": "fa:pen",
|
||||
"iconUrl": "nodes/test-node/icon.svg",
|
||||
"name": "agent",
|
||||
"title": "agent",
|
||||
},
|
||||
"type": "node",
|
||||
},
|
||||
{
|
||||
"key": "chain",
|
||||
"properties": {
|
||||
"description": "example mock chain node",
|
||||
"displayName": "chain",
|
||||
"group": [],
|
||||
"icon": "fa:pen",
|
||||
"iconUrl": "nodes/test-node/icon.svg",
|
||||
"name": "chain",
|
||||
"title": "chain",
|
||||
},
|
||||
"type": "node",
|
||||
},
|
||||
{
|
||||
"key": "n8n-nodes-base.aiTransform",
|
||||
"properties": {
|
||||
"description": "",
|
||||
"displayName": "n8n-nodes-base.aiTransform",
|
||||
"group": [],
|
||||
"icon": "fa:pen",
|
||||
"iconUrl": "nodes/test-node/icon.svg",
|
||||
"name": "n8n-nodes-base.aiTransform",
|
||||
"title": "n8n-nodes-base.aiTransform",
|
||||
},
|
||||
"type": "node",
|
||||
},
|
||||
{
|
||||
"key": "AI Other",
|
||||
"properties": {
|
||||
"description": "Embeddings, Vector Stores, LLMs and other AI nodes",
|
||||
"icon": "robot",
|
||||
"title": "Other AI Nodes",
|
||||
},
|
||||
"type": "view",
|
||||
},
|
||||
],
|
||||
"subtitle": "Select an AI Node to add to your workflow",
|
||||
"title": "AI Nodes",
|
||||
"value": "AI",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`viewsData > AIView > should return ai view without ai transform node if ask ai is not enabled 1`] = `
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"key": "ai_templates_root",
|
||||
"properties": {
|
||||
"description": "See what's possible and get started 5x faster",
|
||||
"icon": "box-open",
|
||||
"key": "ai_templates_root",
|
||||
"tag": {
|
||||
"text": "Recommended",
|
||||
"type": "info",
|
||||
},
|
||||
"title": "AI Templates",
|
||||
"url": "template-repository-url.n8n.io?test=value&utm_user_role=AdvancedAI",
|
||||
},
|
||||
"type": "link",
|
||||
"uuid": "ai_templates_root",
|
||||
},
|
||||
{
|
||||
"key": "agent",
|
||||
"properties": {
|
||||
"description": "example mock agent node",
|
||||
"displayName": "agent",
|
||||
"group": [],
|
||||
"icon": "fa:pen",
|
||||
"iconUrl": "nodes/test-node/icon.svg",
|
||||
"name": "agent",
|
||||
"title": "agent",
|
||||
},
|
||||
"type": "node",
|
||||
},
|
||||
{
|
||||
"key": "chain",
|
||||
"properties": {
|
||||
"description": "example mock chain node",
|
||||
"displayName": "chain",
|
||||
"group": [],
|
||||
"icon": "fa:pen",
|
||||
"iconUrl": "nodes/test-node/icon.svg",
|
||||
"name": "chain",
|
||||
"title": "chain",
|
||||
},
|
||||
"type": "node",
|
||||
},
|
||||
{
|
||||
"key": "AI Other",
|
||||
"properties": {
|
||||
"description": "Embeddings, Vector Stores, LLMs and other AI nodes",
|
||||
"icon": "robot",
|
||||
"title": "Other AI Nodes",
|
||||
},
|
||||
"type": "view",
|
||||
},
|
||||
],
|
||||
"subtitle": "Select an AI Node to add to your workflow",
|
||||
"title": "AI Nodes",
|
||||
"value": "AI",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`viewsData > AIView > should return ai view without ai transform node if ask ai is not enabled and node is not in the list 1`] = `
|
||||
exports[`viewsData > AIView > should return the AI view 1`] = `
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
|
||||
+4
-15
@@ -93,25 +93,14 @@ describe('viewsData', () => {
|
||||
});
|
||||
|
||||
describe('AIView', () => {
|
||||
test('should return ai view with ai transform node', () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(true);
|
||||
|
||||
test('should return the AI view', () => {
|
||||
expect(AIView([])).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should return ai view without ai transform node if ask ai is not enabled', () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false);
|
||||
test('should not include the deprecated AI Transform node', () => {
|
||||
const result = AIView([]);
|
||||
|
||||
expect(AIView([])).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should return ai view without ai transform node if ask ai is not enabled and node is not in the list', () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
vi.spyOn(settingsStore, 'isAskAiEnabled', 'get').mockReturnValue(false);
|
||||
|
||||
expect(AIView([])).toMatchSnapshot();
|
||||
expect(result.items.some((item) => item.key === AI_TRANSFORM_NODE_TYPE)).toBe(false);
|
||||
});
|
||||
|
||||
test('should include Message an Agent node before the agent node when agents module is active', () => {
|
||||
|
||||
@@ -207,10 +207,6 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
|
||||
TEMPLATE_CATEGORY_AI,
|
||||
);
|
||||
|
||||
const askAiEnabled = settingsStore.isAskAiEnabled;
|
||||
const aiTransformNode = nodeTypesStore.getNodeType(AI_TRANSFORM_NODE_TYPE);
|
||||
const transformNode = askAiEnabled && aiTransformNode ? [getNodeView(aiTransformNode)] : [];
|
||||
|
||||
const callouts: NodeViewItem[] = [getAiTemplatesCallout(aiTemplatesURL)];
|
||||
|
||||
return {
|
||||
@@ -224,7 +220,6 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
|
||||
...messageAnAgentNode,
|
||||
...agentNodes,
|
||||
...chainNodes,
|
||||
...transformNode,
|
||||
...evaluationNode,
|
||||
{
|
||||
key: AI_OTHERS_NODE_CREATOR_VIEW,
|
||||
|
||||
@@ -21,6 +21,7 @@ export class AiTransform implements INodeType {
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Modify data based on instructions written in plain english',
|
||||
hidden: true,
|
||||
defaults: {
|
||||
name: 'AI Transform',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user