@atom8n/n8n-benchmark 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/Dockerfile +63 -0
- package/README.md +122 -0
- package/bin/n8n-benchmark +13 -0
- package/biome.jsonc +7 -0
- package/dist/build.tsbuildinfo +1 -0
- package/dist/commands/list.d.ts +8 -0
- package/dist/commands/list.js +23 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/run.d.ts +24 -0
- package/dist/commands/run.js +128 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/config/common-flags.d.ts +1 -0
- package/dist/config/common-flags.js +9 -0
- package/dist/config/common-flags.js.map +1 -0
- package/dist/n8n-api-client/authenticated-n8n-api-client.d.ts +15 -0
- package/dist/n8n-api-client/authenticated-n8n-api-client.js +67 -0
- package/dist/n8n-api-client/authenticated-n8n-api-client.js.map +1 -0
- package/dist/n8n-api-client/credentials-api-client.d.ts +9 -0
- package/dist/n8n-api-client/credentials-api-client.js +24 -0
- package/dist/n8n-api-client/credentials-api-client.js.map +1 -0
- package/dist/n8n-api-client/data-table-api-client.d.ts +9 -0
- package/dist/n8n-api-client/data-table-api-client.js +23 -0
- package/dist/n8n-api-client/data-table-api-client.js.map +1 -0
- package/dist/n8n-api-client/n8n-api-client.d.ts +13 -0
- package/dist/n8n-api-client/n8n-api-client.js +82 -0
- package/dist/n8n-api-client/n8n-api-client.js.map +1 -0
- package/dist/n8n-api-client/n8n-api-client.types.d.ts +21 -0
- package/dist/n8n-api-client/n8n-api-client.types.js +3 -0
- package/dist/n8n-api-client/n8n-api-client.types.js.map +1 -0
- package/dist/n8n-api-client/project-api-client.d.ts +6 -0
- package/dist/n8n-api-client/project-api-client.js +14 -0
- package/dist/n8n-api-client/project-api-client.js.map +1 -0
- package/dist/n8n-api-client/workflows-api-client.d.ts +11 -0
- package/dist/n8n-api-client/workflows-api-client.js +30 -0
- package/dist/n8n-api-client/workflows-api-client.js.map +1 -0
- package/dist/scenario/scenario-data-loader.d.ts +13 -0
- package/dist/scenario/scenario-data-loader.js +84 -0
- package/dist/scenario/scenario-data-loader.js.map +1 -0
- package/dist/scenario/scenario-loader.d.ts +7 -0
- package/dist/scenario/scenario-loader.js +101 -0
- package/dist/scenario/scenario-loader.js.map +1 -0
- package/dist/test-execution/app-metrics-poller.d.ts +13 -0
- package/dist/test-execution/app-metrics-poller.js +54 -0
- package/dist/test-execution/app-metrics-poller.js.map +1 -0
- package/dist/test-execution/k6-executor.d.ts +33 -0
- package/dist/test-execution/k6-executor.js +120 -0
- package/dist/test-execution/k6-executor.js.map +1 -0
- package/dist/test-execution/k6-summary.d.ts +82 -0
- package/dist/test-execution/k6-summary.js +2 -0
- package/dist/test-execution/k6-summary.js.map +1 -0
- package/dist/test-execution/prometheus-metrics-parser.d.ts +9 -0
- package/dist/test-execution/prometheus-metrics-parser.js +44 -0
- package/dist/test-execution/prometheus-metrics-parser.js.map +1 -0
- package/dist/test-execution/scenario-data-importer.d.ts +21 -0
- package/dist/test-execution/scenario-data-importer.js +108 -0
- package/dist/test-execution/scenario-data-importer.js.map +1 -0
- package/dist/test-execution/scenario-runner.d.ts +18 -0
- package/dist/test-execution/scenario-runner.js +46 -0
- package/dist/test-execution/scenario-runner.js.map +1 -0
- package/dist/test-execution/test-report.d.ts +56 -0
- package/dist/test-execution/test-report.js +65 -0
- package/dist/test-execution/test-report.js.map +1 -0
- package/dist/types/scenario.d.ts +16 -0
- package/dist/types/scenario.js +3 -0
- package/dist/types/scenario.js.map +1 -0
- package/eslint.config.mjs +22 -0
- package/infra/.terraform.lock.hcl +60 -0
- package/infra/benchmark-env.tf +54 -0
- package/infra/modules/benchmark-vm/output.tf +11 -0
- package/infra/modules/benchmark-vm/vars.tf +29 -0
- package/infra/modules/benchmark-vm/vm.tf +126 -0
- package/infra/output.tf +16 -0
- package/infra/providers.tf +23 -0
- package/infra/vars.tf +34 -0
- package/package.json +55 -0
- package/scenarios/binary-data/binary-data.json +67 -0
- package/scenarios/binary-data/binary-data.manifest.json +7 -0
- package/scenarios/binary-data/binary-data.script.js +29 -0
- package/scenarios/credential-http-node/credential-bearer.json +8 -0
- package/scenarios/credential-http-node/credential-http-node.json +241 -0
- package/scenarios/credential-http-node/credential-http-node.manifest.json +10 -0
- package/scenarios/credential-http-node/credential-http-node.script.js +30 -0
- package/scenarios/data-table-node/data-table-node.json +168 -0
- package/scenarios/data-table-node/data-table-node.manifest.json +10 -0
- package/scenarios/data-table-node/data-table-node.script.js +38 -0
- package/scenarios/data-table-node/data-table.json +25 -0
- package/scenarios/http-node/http-node.json +213 -0
- package/scenarios/http-node/http-node.manifest.json +7 -0
- package/scenarios/http-node/http-node.script.js +30 -0
- package/scenarios/js-code-node/js-code-node.json +96 -0
- package/scenarios/js-code-node/js-code-node.manifest.json +7 -0
- package/scenarios/js-code-node/js-code-node.script.js +29 -0
- package/scenarios/multiple-webhooks/multiple-webhooks.manifest.json +20 -0
- package/scenarios/multiple-webhooks/multiple-webhooks.script.js +19 -0
- package/scenarios/multiple-webhooks/multiple-webhooks1.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks10.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks2.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks3.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks4.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks5.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks6.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks7.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks8.json +25 -0
- package/scenarios/multiple-webhooks/multiple-webhooks9.json +25 -0
- package/scenarios/py-code-node/py-code-node.json +98 -0
- package/scenarios/py-code-node/py-code-node.manifest.json +7 -0
- package/scenarios/py-code-node/py-code-node.script.js +29 -0
- package/scenarios/scenario.schema.json +51 -0
- package/scenarios/set-node-expressions/set-node-expressions.json +91 -0
- package/scenarios/set-node-expressions/set-node-expressions.manifest.json +7 -0
- package/scenarios/set-node-expressions/set-node-expressions.script.js +18 -0
- package/scenarios/single-webhook/single-webhook.json +25 -0
- package/scenarios/single-webhook/single-webhook.manifest.json +7 -0
- package/scenarios/single-webhook/single-webhook.script.js +18 -0
- package/scripts/bootstrap.sh +63 -0
- package/scripts/clients/docker-compose-client.mjs +45 -0
- package/scripts/clients/ssh-client.mjs +37 -0
- package/scripts/clients/terraform-client.mjs +71 -0
- package/scripts/destroy-cloud-env.mjs +86 -0
- package/scripts/mock-api/mappings/mockApiData.json +92110 -0
- package/scripts/n8n-setups/postgres/docker-compose.yml +76 -0
- package/scripts/n8n-setups/postgres/setup.mjs +15 -0
- package/scripts/n8n-setups/scaling-multi-main/docker-compose.yml +230 -0
- package/scripts/n8n-setups/scaling-multi-main/nginx.conf +24 -0
- package/scripts/n8n-setups/scaling-multi-main/setup.mjs +15 -0
- package/scripts/n8n-setups/scaling-single-main/docker-compose.yml +174 -0
- package/scripts/n8n-setups/scaling-single-main/setup.mjs +15 -0
- package/scripts/n8n-setups/sqlite/docker-compose.yml +55 -0
- package/scripts/n8n-setups/sqlite/setup.mjs +15 -0
- package/scripts/provision-cloud-env.mjs +36 -0
- package/scripts/run-for-n8n-setup.mjs +175 -0
- package/scripts/run-in-cloud.mjs +167 -0
- package/scripts/run-locally.mjs +73 -0
- package/scripts/run.mjs +192 -0
- package/scripts/utils/flags.mjs +20 -0
- package/src/commands/list.ts +26 -0
- package/src/commands/run.ts +140 -0
- package/src/config/common-flags.ts +6 -0
- package/src/n8n-api-client/authenticated-n8n-api-client.ts +88 -0
- package/src/n8n-api-client/credentials-api-client.ts +28 -0
- package/src/n8n-api-client/data-table-api-client.ts +30 -0
- package/src/n8n-api-client/n8n-api-client.ts +85 -0
- package/src/n8n-api-client/n8n-api-client.types.ts +27 -0
- package/src/n8n-api-client/project-api-client.ts +11 -0
- package/src/n8n-api-client/workflows-api-client.ts +38 -0
- package/src/scenario/scenario-data-loader.ts +75 -0
- package/src/scenario/scenario-loader.ts +90 -0
- package/src/test-execution/app-metrics-poller.ts +81 -0
- package/src/test-execution/k6-executor.ts +192 -0
- package/src/test-execution/k6-summary.ts +255 -0
- package/src/test-execution/prometheus-metrics-parser.ts +63 -0
- package/src/test-execution/scenario-data-importer.ts +165 -0
- package/src/test-execution/scenario-runner.ts +76 -0
- package/src/test-execution/test-report.ts +152 -0
- package/src/types/scenario.ts +33 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
|
|
3
|
+
import { testScenariosPath } from '@/config/common-flags';
|
|
4
|
+
import { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
|
|
5
|
+
import { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
|
|
6
|
+
import { ScenarioLoader } from '@/scenario/scenario-loader';
|
|
7
|
+
import type { K6Tag } from '@/test-execution/k6-executor';
|
|
8
|
+
import { K6Executor } from '@/test-execution/k6-executor';
|
|
9
|
+
import { ScenarioRunner } from '@/test-execution/scenario-runner';
|
|
10
|
+
|
|
11
|
+
export default class RunCommand extends Command {
|
|
12
|
+
static description = 'Run all (default) or specified test scenarios';
|
|
13
|
+
|
|
14
|
+
static flags = {
|
|
15
|
+
testScenariosPath,
|
|
16
|
+
scenarioFilter: Flags.string({
|
|
17
|
+
char: 'f',
|
|
18
|
+
description: 'Filter scenarios by name',
|
|
19
|
+
}),
|
|
20
|
+
scenarioNamePrefix: Flags.string({
|
|
21
|
+
description: 'Prefix for the scenario name',
|
|
22
|
+
default: 'Unnamed',
|
|
23
|
+
}),
|
|
24
|
+
n8nBaseUrl: Flags.string({
|
|
25
|
+
description: 'The base URL for the n8n instance',
|
|
26
|
+
default: 'http://localhost:5678',
|
|
27
|
+
env: 'N8N_BASE_URL',
|
|
28
|
+
}),
|
|
29
|
+
n8nUserEmail: Flags.string({
|
|
30
|
+
description: 'The email address of the n8n user',
|
|
31
|
+
default: 'benchmark-user@n8n.io',
|
|
32
|
+
env: 'N8N_USER_EMAIL',
|
|
33
|
+
}),
|
|
34
|
+
k6ExecutablePath: Flags.string({
|
|
35
|
+
doc: 'The path to the k6 binary',
|
|
36
|
+
default: 'k6',
|
|
37
|
+
env: 'K6_PATH',
|
|
38
|
+
}),
|
|
39
|
+
k6ApiToken: Flags.string({
|
|
40
|
+
doc: 'The API token for k6 cloud',
|
|
41
|
+
default: undefined,
|
|
42
|
+
env: 'K6_API_TOKEN',
|
|
43
|
+
}),
|
|
44
|
+
out: Flags.string({
|
|
45
|
+
description: 'The --out flag for k6',
|
|
46
|
+
default: undefined,
|
|
47
|
+
env: 'K6_OUT',
|
|
48
|
+
}),
|
|
49
|
+
resultWebhookUrl: Flags.string({
|
|
50
|
+
doc: 'The URL where the benchmark results should be sent to',
|
|
51
|
+
default: undefined,
|
|
52
|
+
env: 'BENCHMARK_RESULT_WEBHOOK_URL',
|
|
53
|
+
}),
|
|
54
|
+
resultWebhookAuthHeader: Flags.string({
|
|
55
|
+
doc: 'The Authorization header value for the benchmark results webhook',
|
|
56
|
+
default: undefined,
|
|
57
|
+
env: 'BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER',
|
|
58
|
+
}),
|
|
59
|
+
n8nUserPassword: Flags.string({
|
|
60
|
+
description: 'The password of the n8n user',
|
|
61
|
+
default: 'VerySecret!123',
|
|
62
|
+
env: 'N8N_USER_PASSWORD',
|
|
63
|
+
}),
|
|
64
|
+
tags: Flags.string({
|
|
65
|
+
char: 't',
|
|
66
|
+
description: 'Tags to attach to the run. Comma separated list of key=value pairs',
|
|
67
|
+
}),
|
|
68
|
+
vus: Flags.integer({
|
|
69
|
+
description: 'Number of concurrent requests to make',
|
|
70
|
+
default: 5,
|
|
71
|
+
}),
|
|
72
|
+
duration: Flags.string({
|
|
73
|
+
description: 'Duration of the test with a unit, e.g. 1m',
|
|
74
|
+
default: '1m',
|
|
75
|
+
}),
|
|
76
|
+
collectAppMetrics: Flags.boolean({
|
|
77
|
+
description: 'Collect app metrics from the /metrics endpoint during test runs',
|
|
78
|
+
default: false,
|
|
79
|
+
env: 'COLLECT_APP_METRICS',
|
|
80
|
+
}),
|
|
81
|
+
appMetricsPollInterval: Flags.integer({
|
|
82
|
+
description: 'App metrics polling interval in milliseconds',
|
|
83
|
+
default: 5000,
|
|
84
|
+
env: 'APP_METRICS_POLL_INTERVAL',
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
async run() {
|
|
89
|
+
const { flags } = await this.parse(RunCommand);
|
|
90
|
+
const tags = await this.parseTags();
|
|
91
|
+
const scenarioLoader = new ScenarioLoader();
|
|
92
|
+
|
|
93
|
+
const scenarioRunner = new ScenarioRunner(
|
|
94
|
+
new N8nApiClient(flags.n8nBaseUrl),
|
|
95
|
+
new ScenarioDataFileLoader(),
|
|
96
|
+
new K6Executor({
|
|
97
|
+
duration: flags.duration,
|
|
98
|
+
vus: flags.vus,
|
|
99
|
+
k6Out: flags.out,
|
|
100
|
+
k6ExecutablePath: flags.k6ExecutablePath,
|
|
101
|
+
k6ApiToken: flags.k6ApiToken,
|
|
102
|
+
n8nApiBaseUrl: flags.n8nBaseUrl,
|
|
103
|
+
tags,
|
|
104
|
+
resultsWebhook: flags.resultWebhookUrl
|
|
105
|
+
? {
|
|
106
|
+
url: flags.resultWebhookUrl,
|
|
107
|
+
authHeader: flags.resultWebhookAuthHeader,
|
|
108
|
+
}
|
|
109
|
+
: undefined,
|
|
110
|
+
appMetricsPolling: flags.collectAppMetrics
|
|
111
|
+
? {
|
|
112
|
+
enabled: true,
|
|
113
|
+
intervalMs: flags.appMetricsPollInterval,
|
|
114
|
+
}
|
|
115
|
+
: undefined,
|
|
116
|
+
}),
|
|
117
|
+
{
|
|
118
|
+
email: flags.n8nUserEmail,
|
|
119
|
+
password: flags.n8nUserPassword,
|
|
120
|
+
},
|
|
121
|
+
flags.scenarioNamePrefix,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath, flags.scenarioFilter);
|
|
125
|
+
|
|
126
|
+
await scenarioRunner.runManyScenarios(allScenarios);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async parseTags(): Promise<K6Tag[]> {
|
|
130
|
+
const { flags } = await this.parse(RunCommand);
|
|
131
|
+
if (!flags.tags) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return flags.tags.split(',').map((tag) => {
|
|
136
|
+
const [name, value] = tag.split('=');
|
|
137
|
+
return { name, value };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AxiosRequestConfig } from 'axios';
|
|
2
|
+
|
|
3
|
+
import { N8nApiClient } from './n8n-api-client';
|
|
4
|
+
|
|
5
|
+
export class AuthenticatedN8nApiClient extends N8nApiClient {
|
|
6
|
+
constructor(
|
|
7
|
+
apiBaseUrl: string,
|
|
8
|
+
private readonly authCookie: string,
|
|
9
|
+
) {
|
|
10
|
+
super(apiBaseUrl);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static async createUsingUsernameAndPassword(
|
|
14
|
+
apiClient: N8nApiClient,
|
|
15
|
+
loginDetails: {
|
|
16
|
+
email: string;
|
|
17
|
+
password: string;
|
|
18
|
+
},
|
|
19
|
+
): Promise<AuthenticatedN8nApiClient> {
|
|
20
|
+
const response = await apiClient.restApiRequest('/login', {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
data: {
|
|
23
|
+
emailOrLdapLoginId: loginDetails.email,
|
|
24
|
+
password: loginDetails.password,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (response.data === 'n8n is starting up. Please wait') {
|
|
29
|
+
await apiClient.delay(1000);
|
|
30
|
+
return await this.createUsingUsernameAndPassword(apiClient, loginDetails);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cookieHeader = response.headers['set-cookie'];
|
|
34
|
+
const authCookie = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader;
|
|
35
|
+
if (!authCookie) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'Did not receive authentication cookie even tho login succeeded: ' +
|
|
38
|
+
JSON.stringify(
|
|
39
|
+
{
|
|
40
|
+
status: response.status,
|
|
41
|
+
headers: response.headers,
|
|
42
|
+
data: response.data,
|
|
43
|
+
},
|
|
44
|
+
null,
|
|
45
|
+
2,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new AuthenticatedN8nApiClient(apiClient.apiBaseUrl, authCookie);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async get<T>(endpoint: string) {
|
|
54
|
+
return await this.authenticatedRequest<T>(endpoint, {
|
|
55
|
+
method: 'GET',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async post<T>(endpoint: string, data: unknown) {
|
|
60
|
+
return await this.authenticatedRequest<T>(endpoint, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
data,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async patch<T>(endpoint: string, data: unknown) {
|
|
67
|
+
return await this.authenticatedRequest<T>(endpoint, {
|
|
68
|
+
method: 'PATCH',
|
|
69
|
+
data,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async delete<T>(endpoint: string) {
|
|
74
|
+
return await this.authenticatedRequest<T>(endpoint, {
|
|
75
|
+
method: 'DELETE',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
protected async authenticatedRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
|
|
80
|
+
return await this.restApiRequest<T>(endpoint, {
|
|
81
|
+
...init,
|
|
82
|
+
headers: {
|
|
83
|
+
...init.headers,
|
|
84
|
+
cookie: this.authCookie,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Credential } from '@/n8n-api-client/n8n-api-client.types';
|
|
2
|
+
|
|
3
|
+
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
|
4
|
+
|
|
5
|
+
export class CredentialApiClient {
|
|
6
|
+
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
|
7
|
+
|
|
8
|
+
async getAllCredentials(): Promise<Credential[]> {
|
|
9
|
+
const response = await this.apiClient.get<{ count: number; data: Credential[] }>(
|
|
10
|
+
'/credentials',
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
return response.data.data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async createCredential(credential: Credential): Promise<Credential> {
|
|
17
|
+
const response = await this.apiClient.post<{ data: Credential }>('/credentials', {
|
|
18
|
+
...credential,
|
|
19
|
+
id: undefined,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return response.data.data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async deleteCredential(credentialId: Credential['id']): Promise<void> {
|
|
26
|
+
await this.apiClient.delete(`/credentials/${credentialId}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DataTable } from '@/n8n-api-client/n8n-api-client.types';
|
|
2
|
+
|
|
3
|
+
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
|
4
|
+
|
|
5
|
+
export class DataTableApiClient {
|
|
6
|
+
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
|
7
|
+
|
|
8
|
+
async getAllDataTables(): Promise<DataTable[]> {
|
|
9
|
+
const response = await this.apiClient.get<{ data: { count: number; data: DataTable[] } }>(
|
|
10
|
+
'/data-tables-global',
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
return response.data.data.data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async deleteDataTable(projectId: string, dataTableId: DataTable['id']): Promise<void> {
|
|
17
|
+
await this.apiClient.delete(`/projects/${projectId}/data-tables/${dataTableId}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async createDataTable(projectId: string, dataTable: DataTable): Promise<DataTable> {
|
|
21
|
+
const response = await this.apiClient.post<{ data: DataTable }>(
|
|
22
|
+
`/projects/${projectId}/data-tables`,
|
|
23
|
+
{
|
|
24
|
+
...dataTable,
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return response.data.data;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
|
|
4
|
+
export class N8nApiClient {
|
|
5
|
+
constructor(readonly apiBaseUrl: string) {}
|
|
6
|
+
|
|
7
|
+
async waitForInstanceToBecomeOnline(): Promise<void> {
|
|
8
|
+
const HEALTH_ENDPOINT = 'healthz';
|
|
9
|
+
const START_TIME = Date.now();
|
|
10
|
+
const INTERVAL_MS = 1000;
|
|
11
|
+
const TIMEOUT_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
while (Date.now() - START_TIME < TIMEOUT_MS) {
|
|
14
|
+
try {
|
|
15
|
+
const response = await axios.request<{ status: 'ok' }>({
|
|
16
|
+
url: `${this.apiBaseUrl}/${HEALTH_ENDPOINT}`,
|
|
17
|
+
method: 'GET',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (response.status === 200 && response.data.status === 'ok') {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
} catch {}
|
|
24
|
+
|
|
25
|
+
console.log(`n8n instance not online yet, retrying in ${INTERVAL_MS / 1000} seconds...`);
|
|
26
|
+
await this.delay(INTERVAL_MS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error(`n8n instance did not come online within ${TIMEOUT_MS / 1000} seconds`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async setupOwnerIfNeeded(loginDetails: { email: string; password: string }) {
|
|
33
|
+
const response = await this.restApiRequest<{ message: string }>('/owner/setup', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
data: {
|
|
36
|
+
email: loginDetails.email,
|
|
37
|
+
password: loginDetails.password,
|
|
38
|
+
firstName: 'Test',
|
|
39
|
+
lastName: 'User',
|
|
40
|
+
},
|
|
41
|
+
// Don't throw on non-2xx responses
|
|
42
|
+
validateStatus: () => true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const responsePayload = response.data;
|
|
46
|
+
|
|
47
|
+
if (response.status === 200) {
|
|
48
|
+
console.log('Owner setup successful');
|
|
49
|
+
} else if (response.status === 400) {
|
|
50
|
+
if (responsePayload.message === 'Instance owner already setup')
|
|
51
|
+
console.log('Owner already set up');
|
|
52
|
+
} else if (response.status === 404) {
|
|
53
|
+
// The n8n instance setup owner endpoint not be available yet even tho
|
|
54
|
+
// the health endpoint returns ok. In this case we simply retry.
|
|
55
|
+
console.log('Owner setup endpoint not available yet, retrying in 1s...');
|
|
56
|
+
await this.delay(1000);
|
|
57
|
+
await this.setupOwnerIfNeeded(loginDetails);
|
|
58
|
+
} else {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Owner setup failed with status ${response.status}: ${responsePayload.message}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async restApiRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
|
|
66
|
+
try {
|
|
67
|
+
return await axios.request<T>({
|
|
68
|
+
...init,
|
|
69
|
+
url: this.getRestEndpointUrl(endpoint),
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
const error = e as AxiosError;
|
|
73
|
+
console.error(`[ERROR] Request failed ${init.method} ${endpoint}`, error?.response?.data);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async delay(ms: number): Promise<void> {
|
|
79
|
+
return await new Promise((resolve) => setTimeout(resolve, ms));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected getRestEndpointUrl(endpoint: string) {
|
|
83
|
+
return `${this.apiBaseUrl}/rest${endpoint}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* n8n workflow. This is a simplified version of the actual workflow object.
|
|
3
|
+
*/
|
|
4
|
+
export type Workflow = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
versionId: string;
|
|
8
|
+
tags?: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type Credential = {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
type: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DataTableColumn = {
|
|
18
|
+
name: string;
|
|
19
|
+
type: 'string' | 'number' | 'boolean' | 'date';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DataTable = {
|
|
23
|
+
id?: string;
|
|
24
|
+
projectId?: number;
|
|
25
|
+
name: string;
|
|
26
|
+
columns: DataTableColumn[];
|
|
27
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
|
2
|
+
|
|
3
|
+
export class ProjectApiClient {
|
|
4
|
+
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
|
5
|
+
|
|
6
|
+
async getPersonalProject(): Promise<string> {
|
|
7
|
+
const response = await this.apiClient.get<{ data: { id: string } }>('/projects/personal');
|
|
8
|
+
|
|
9
|
+
return response.data.data.id;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Workflow } from '@/n8n-api-client/n8n-api-client.types';
|
|
2
|
+
|
|
3
|
+
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
|
|
4
|
+
|
|
5
|
+
export class WorkflowApiClient {
|
|
6
|
+
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
|
|
7
|
+
|
|
8
|
+
async getAllWorkflows(): Promise<Workflow[]> {
|
|
9
|
+
const response = await this.apiClient.get<{ count: number; data: Workflow[] }>('/workflows');
|
|
10
|
+
|
|
11
|
+
return response.data.data;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async createWorkflow(workflow: unknown): Promise<Workflow> {
|
|
15
|
+
const response = await this.apiClient.post<{ data: Workflow }>('/workflows', workflow);
|
|
16
|
+
|
|
17
|
+
return response.data.data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async activateWorkflow(workflow: Workflow): Promise<Workflow> {
|
|
21
|
+
const response = await this.apiClient.post<{ data: Workflow }>(
|
|
22
|
+
`/workflows/${workflow.id}/activate`,
|
|
23
|
+
{
|
|
24
|
+
versionId: workflow.versionId,
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return response.data.data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async archiveWorkflow(workflowId: Workflow['id']): Promise<void> {
|
|
32
|
+
await this.apiClient.post(`/workflows/${workflowId}/archive`, {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async deleteWorkflow(workflowId: Workflow['id']): Promise<void> {
|
|
36
|
+
await this.apiClient.delete(`/workflows/${workflowId}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { Workflow, Credential, DataTable } from '@/n8n-api-client/n8n-api-client.types';
|
|
5
|
+
import type { Scenario } from '@/types/scenario';
|
|
6
|
+
|
|
7
|
+
export type LoadableScenarioData = {
|
|
8
|
+
workflows: Workflow[];
|
|
9
|
+
credentials: Credential[];
|
|
10
|
+
dataTable: DataTable | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Loads scenario data files from FS
|
|
15
|
+
*/
|
|
16
|
+
export class ScenarioDataFileLoader {
|
|
17
|
+
async loadDataForScenario(scenario: Scenario): Promise<LoadableScenarioData> {
|
|
18
|
+
const workflows = await Promise.all(
|
|
19
|
+
scenario.scenarioData.workflowFiles?.map((workflowFilePath) =>
|
|
20
|
+
this.loadSingleWorkflowFromFile(path.join(scenario.scenarioDirPath, workflowFilePath)),
|
|
21
|
+
) ?? [],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const credentials = await Promise.all(
|
|
25
|
+
scenario.scenarioData.credentialFiles?.map((credentialFilePath) =>
|
|
26
|
+
this.loadSingleCredentialFromFile(path.join(scenario.scenarioDirPath, credentialFilePath)),
|
|
27
|
+
) ?? [],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const dataTable = scenario.scenarioData.dataTableFile
|
|
31
|
+
? this.loadSingleDataTableFromFile(
|
|
32
|
+
path.join(scenario.scenarioDirPath, scenario.scenarioData.dataTableFile),
|
|
33
|
+
)
|
|
34
|
+
: null;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
workflows,
|
|
38
|
+
credentials,
|
|
39
|
+
dataTable,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private loadSingleCredentialFromFile(credentialFilePath: string): Credential {
|
|
44
|
+
const fileContent = fs.readFileSync(credentialFilePath, 'utf8');
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fileContent) as Credential;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const e = error as Error;
|
|
50
|
+
throw new Error(`Failed to parse credential file ${credentialFilePath}: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private loadSingleWorkflowFromFile(workflowFilePath: string): Workflow {
|
|
55
|
+
const fileContent = fs.readFileSync(workflowFilePath, 'utf8');
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fileContent) as Workflow;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const e = error as Error;
|
|
61
|
+
throw new Error(`Failed to parse workflow file ${workflowFilePath}: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private loadSingleDataTableFromFile(dataTableFilePath: string): DataTable {
|
|
66
|
+
const fileContent = fs.readFileSync(dataTableFilePath, 'utf8');
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(fileContent) as DataTable;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const e = error as Error;
|
|
72
|
+
throw new Error(`Failed to parse data table file ${dataTableFilePath}: ${e.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
import type { Scenario, ScenarioManifest } from '@/types/scenario';
|
|
6
|
+
|
|
7
|
+
export class ScenarioLoader {
|
|
8
|
+
/**
|
|
9
|
+
* Loads all scenarios from the given path
|
|
10
|
+
*/
|
|
11
|
+
loadAll(pathToScenarios: string, filter?: string): Scenario[] {
|
|
12
|
+
pathToScenarios = path.resolve(pathToScenarios);
|
|
13
|
+
const scenarioFolders = fs
|
|
14
|
+
.readdirSync(pathToScenarios, { withFileTypes: true })
|
|
15
|
+
.filter((dirent) => dirent.isDirectory())
|
|
16
|
+
.map((dirent) => dirent.name);
|
|
17
|
+
|
|
18
|
+
const scenarios: Scenario[] = [];
|
|
19
|
+
|
|
20
|
+
for (const folder of scenarioFolders) {
|
|
21
|
+
if (filter && folder.toLowerCase() !== filter.toLowerCase()) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const scenarioPath = path.join(pathToScenarios, folder);
|
|
25
|
+
const manifestFileName = `${folder}.manifest.json`;
|
|
26
|
+
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);
|
|
27
|
+
if (!fs.existsSync(scenarioManifestPath)) {
|
|
28
|
+
console.warn(`Scenario at ${scenarioPath} is missing the ${manifestFileName} file`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Load the scenario manifest file
|
|
33
|
+
const [scenario, validationErrors] =
|
|
34
|
+
this.loadAndValidateScenarioManifest(scenarioManifestPath);
|
|
35
|
+
if (validationErrors) {
|
|
36
|
+
console.warn(
|
|
37
|
+
`Scenario at ${scenarioPath} has the following validation errors: ${validationErrors.join(', ')}`,
|
|
38
|
+
);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
scenarios.push({
|
|
43
|
+
...scenario,
|
|
44
|
+
id: this.formScenarioId(scenarioPath),
|
|
45
|
+
scenarioDirPath: scenarioPath,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return scenarios;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private loadAndValidateScenarioManifest(
|
|
53
|
+
scenarioManifestPath: string,
|
|
54
|
+
): [ScenarioManifest, null] | [null, string[]] {
|
|
55
|
+
const [scenario, error] = this.loadScenarioManifest(scenarioManifestPath);
|
|
56
|
+
if (!scenario) {
|
|
57
|
+
return [null, [error]];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const validationErrors: string[] = [];
|
|
61
|
+
|
|
62
|
+
if (!scenario.name) {
|
|
63
|
+
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a name`);
|
|
64
|
+
}
|
|
65
|
+
if (!scenario.description) {
|
|
66
|
+
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a description`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return validationErrors.length === 0 ? [scenario, null] : [null, validationErrors];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private loadScenarioManifest(
|
|
73
|
+
scenarioManifestPath: string,
|
|
74
|
+
): [ScenarioManifest, null] | [null, string] {
|
|
75
|
+
try {
|
|
76
|
+
const scenario = JSON.parse(
|
|
77
|
+
fs.readFileSync(scenarioManifestPath, 'utf8'),
|
|
78
|
+
) as ScenarioManifest;
|
|
79
|
+
|
|
80
|
+
return [scenario, null];
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
|
83
|
+
return [null, `Failed to parse manifest ${scenarioManifestPath}: ${message}`];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private formScenarioId(scenarioPath: string): string {
|
|
88
|
+
return createHash('sha256').update(scenarioPath).digest('hex');
|
|
89
|
+
}
|
|
90
|
+
}
|