first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
mockInstance,
|
||||
testDb,
|
||||
createWorkflowWithTriggerAndHistory,
|
||||
setActiveVersion,
|
||||
createWorkflowHistory,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import fs from 'fs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { ExportWorkflowsCommand } from '@/commands/export/workflow';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { setupTestCommand } from '@test-integration/utils/test-command';
|
||||
|
||||
mockInstance(LoadNodesAndCredentials);
|
||||
|
||||
const command = setupTestCommand(ExportWorkflowsCommand);
|
||||
|
||||
let testOutputDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
|
||||
testOutputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-export-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testOutputDir)) {
|
||||
fs.rmSync(testOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject both --version and --published flags', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory();
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
|
||||
await command.run([
|
||||
`--id=${workflow.id}`,
|
||||
`--version=${workflow.versionId}`,
|
||||
'--published',
|
||||
`--output=${outputFile}`,
|
||||
]);
|
||||
|
||||
expect(fs.existsSync(outputFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('should reject --version with --all flag', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory();
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
|
||||
await command.run(['--all', `--version=${workflow.versionId}`, `--output=${outputFile}`]);
|
||||
|
||||
expect(fs.existsSync(outputFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('should export current draft version when no flags set', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'uuid-draft',
|
||||
parameters: {},
|
||||
name: 'Draft Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData).toMatchObject({
|
||||
id: workflow.id,
|
||||
name: 'Test Workflow',
|
||||
versionId: workflow.versionId,
|
||||
});
|
||||
expect(exportedData.nodes[0].name).toBe('Draft Node');
|
||||
});
|
||||
|
||||
test('should export specified version with --version flag', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'uuid-v1',
|
||||
parameters: {},
|
||||
name: 'Version 1 Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const version1Id = workflow.versionId;
|
||||
|
||||
const newVersionId = nanoid();
|
||||
workflow.versionId = newVersionId;
|
||||
workflow.nodes = [
|
||||
{
|
||||
id: 'uuid-v2',
|
||||
parameters: {},
|
||||
name: 'Version 2 Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
];
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
await createWorkflowHistory(workflow);
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, `--version=${version1Id}`, `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData).toMatchObject({
|
||||
id: workflow.id,
|
||||
versionId: version1Id,
|
||||
});
|
||||
expect(exportedData.nodes[0].name).toBe('Version 1 Node');
|
||||
});
|
||||
|
||||
test('should export published version with --published flag', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'uuid-published',
|
||||
parameters: {},
|
||||
name: 'Published Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const publishedVersionId = workflow.versionId;
|
||||
|
||||
await setActiveVersion(workflow.id, publishedVersionId);
|
||||
|
||||
const draftVersionId = nanoid();
|
||||
workflow.versionId = draftVersionId;
|
||||
workflow.activeVersionId = publishedVersionId;
|
||||
workflow.nodes = [
|
||||
{
|
||||
id: 'uuid-draft',
|
||||
parameters: {},
|
||||
name: 'Draft Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
];
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
await createWorkflowHistory(workflow);
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, '--published', `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData).toMatchObject({
|
||||
id: workflow.id,
|
||||
versionId: publishedVersionId,
|
||||
});
|
||||
expect(exportedData.nodes[0].name).toBe('Published Node');
|
||||
});
|
||||
|
||||
test('should optimize when target version is current draft', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Test Workflow',
|
||||
});
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([
|
||||
`--id=${workflow.id}`,
|
||||
`--version=${workflow.versionId}`,
|
||||
`--output=${outputFile}`,
|
||||
]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData).toMatchObject({
|
||||
id: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
});
|
||||
});
|
||||
|
||||
test('should merge historical nodes with current metadata', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Original Name',
|
||||
nodes: [
|
||||
{
|
||||
id: 'uuid-v1',
|
||||
parameters: {},
|
||||
name: 'Version 1 Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const version1Id = workflow.versionId;
|
||||
|
||||
const newVersionId = nanoid();
|
||||
workflow.versionId = newVersionId;
|
||||
workflow.name = 'Updated Name';
|
||||
workflow.nodes = [
|
||||
{
|
||||
id: 'uuid-v2',
|
||||
parameters: {},
|
||||
name: 'Version 2 Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
];
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
await createWorkflowHistory(workflow);
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, `--version=${version1Id}`, `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData.nodes[0].name).toBe('Version 1 Node');
|
||||
expect(exportedData.versionId).toBe(version1Id);
|
||||
expect(exportedData.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
test('should error when version not found', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory();
|
||||
const nonExistentVersionId = 'non-existent-version';
|
||||
|
||||
await expect(
|
||||
command.run([`--id=${workflow.id}`, `--version=${nonExistentVersionId}`]),
|
||||
).rejects.toThrow(
|
||||
`Version "${nonExistentVersionId}" not found for workflow "${workflow.name}" (${workflow.id})`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should error when --published used on unpublished workflow', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Unpublished Workflow',
|
||||
});
|
||||
|
||||
workflow.activeVersionId = null;
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
|
||||
await expect(command.run([`--id=${workflow.id}`, '--published'])).rejects.toThrow(
|
||||
`No published version found for workflow "${workflow.name}" (${workflow.id})`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should error when workflow not found', async () => {
|
||||
const nonExistentId = 'non-existent-id';
|
||||
|
||||
await expect(command.run([`--id=${nonExistentId}`])).rejects.toThrow(
|
||||
'No workflows found with specified filters',
|
||||
);
|
||||
});
|
||||
|
||||
test('should work without any version flags (existing behavior)', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Test Workflow',
|
||||
});
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData).toMatchObject({
|
||||
id: workflow.id,
|
||||
name: 'Test Workflow',
|
||||
versionId: workflow.versionId,
|
||||
});
|
||||
});
|
||||
|
||||
test('should work with --all flag (existing behavior)', async () => {
|
||||
await createWorkflowWithTriggerAndHistory({ name: 'Workflow 1' });
|
||||
await createWorkflowWithTriggerAndHistory({ name: 'Workflow 2' });
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run(['--all', `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
||||
|
||||
expect(exportedData).toHaveLength(2);
|
||||
|
||||
const workflowNames = exportedData.map((w: any) => w.name);
|
||||
expect(workflowNames).toContain('Workflow 1');
|
||||
expect(workflowNames).toContain('Workflow 2');
|
||||
});
|
||||
|
||||
test('should work with --pretty flag (existing behavior)', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory();
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, '--pretty', `--output=${outputFile}`]);
|
||||
|
||||
const fileContents = fs.readFileSync(outputFile, 'utf-8');
|
||||
expect(fileContents).toContain('\n');
|
||||
expect(fileContents).toMatch(/\s{2}/);
|
||||
});
|
||||
|
||||
test('should export all published versions with --all --published', async () => {
|
||||
const workflow1 = await createWorkflowWithTriggerAndHistory({ name: 'Published Workflow' });
|
||||
const publishedVersionId = workflow1.versionId;
|
||||
await setActiveVersion(workflow1.id, publishedVersionId);
|
||||
|
||||
const draftVersionId = nanoid();
|
||||
workflow1.versionId = draftVersionId;
|
||||
workflow1.activeVersionId = publishedVersionId;
|
||||
await Container.get(WorkflowRepository).save(workflow1);
|
||||
await createWorkflowHistory(workflow1);
|
||||
|
||||
await createWorkflowWithTriggerAndHistory({ name: 'Unpublished Workflow' });
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run(['--all', '--published', `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
||||
|
||||
expect(exportedData).toHaveLength(1);
|
||||
expect(exportedData[0].name).toBe('Published Workflow');
|
||||
expect(exportedData[0].versionId).toBe(publishedVersionId);
|
||||
});
|
||||
|
||||
test('should include versionMetadata with historical name when set', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
name: 'Original Name',
|
||||
});
|
||||
|
||||
const version2Id = nanoid();
|
||||
workflow.versionId = version2Id;
|
||||
workflow.name = 'Updated Name';
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
|
||||
await createWorkflowHistory(workflow, undefined, undefined, {
|
||||
name: 'Version 2 Historical Name',
|
||||
});
|
||||
|
||||
const version3Id = nanoid();
|
||||
workflow.versionId = version3Id;
|
||||
workflow.name = 'Current Name';
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
await createWorkflowHistory(workflow);
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, `--version=${version2Id}`, `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData.name).toBe('Current Name');
|
||||
expect(exportedData.versionId).toBe(version2Id);
|
||||
expect(exportedData.versionMetadata).toEqual({
|
||||
name: 'Version 2 Historical Name',
|
||||
description: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('should include versionMetadata with historical description when set', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({
|
||||
description: 'Original Description',
|
||||
});
|
||||
|
||||
const version2Id = nanoid();
|
||||
workflow.versionId = version2Id;
|
||||
workflow.description = 'Updated Description';
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
|
||||
await createWorkflowHistory(workflow, undefined, undefined, {
|
||||
description: 'Version 2 Historical Description',
|
||||
});
|
||||
|
||||
const version3Id = nanoid();
|
||||
workflow.versionId = version3Id;
|
||||
workflow.description = 'Current Description';
|
||||
await Container.get(WorkflowRepository).save(workflow);
|
||||
await createWorkflowHistory(workflow);
|
||||
|
||||
const outputFile = path.join(testOutputDir, 'output.json');
|
||||
await command.run([`--id=${workflow.id}`, `--version=${version2Id}`, `--output=${outputFile}`]);
|
||||
|
||||
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
|
||||
|
||||
expect(exportedData.description).toBe('Current Description');
|
||||
expect(exportedData.versionId).toBe(version2Id);
|
||||
expect(exportedData.versionMetadata).toEqual({
|
||||
name: null,
|
||||
description: 'Version 2 Historical Description',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user