mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat(core): Support minor levels in n8nNodesApiVersion (no-changelog) (#39308)
Co-authored-by: Benny Neugebauer <[email protected]>
This commit is contained in:
co-authored by
Benny Neugebauer
parent
a3391ba93c
commit
8f9f673b86
@@ -1,16 +1,7 @@
|
||||
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
|
||||
|
||||
/**
|
||||
* Node-authoring API level supported by the runtime that bundles this copy.
|
||||
*
|
||||
* Compatibility rule for community packages:
|
||||
* `package.n8n.n8nNodesApiVersion <= N8N_NODES_API_VERSION`.
|
||||
*
|
||||
* The level mirrors the n8n major for the v3 transition: `1` on master
|
||||
* (which ships as 2.x during the v3 window) and `3` on the `3.x` branch,
|
||||
* where it also serves as the v3 feature flag for node-authoring APIs.
|
||||
*
|
||||
* Lives here, not in `n8n-workflow`, so tooling that cannot depend on the
|
||||
* runtime packages (e.g. `@n8n/node-cli`) can read the same value.
|
||||
* Node-authoring API level this runtime supports, and so the highest
|
||||
* `n8nNodesApiVersion` a node may declare to be installed.
|
||||
*/
|
||||
export const N8N_NODES_API_VERSION = 1;
|
||||
export const N8N_NODES_API_VERSION = '1.0';
|
||||
|
||||
@@ -17,8 +17,9 @@ register at install time and the failure is opaque to the user.
|
||||
This rule enforces the structural contract:
|
||||
|
||||
- `package.json` must contain an `n8n` object.
|
||||
- `n8n.n8nNodesApiVersion` must be present and a positive integer. It must live
|
||||
inside `n8n`, not at the root.
|
||||
- `n8n.n8nNodesApiVersion` must be present and a positive integer, or a
|
||||
`"<major>"` or `"<major>.<minor>"` string such as `"3"` or `"3.1"`. It must
|
||||
live inside `n8n`, not at the root.
|
||||
- `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`.
|
||||
- `n8n.credentials`, if present, must be an array of strings, each starting
|
||||
with `dist/`.
|
||||
@@ -53,7 +54,7 @@ the templates published by `@n8n/node-cli` and `@n8n/create-node`.
|
||||
{
|
||||
"name": "n8n-nodes-example",
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": "1",
|
||||
"n8nNodesApiVersion": "v1",
|
||||
"nodes": ["nodes/Foo/Foo.node.js"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"watch": "tsc --watch --project tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@typescript-eslint/typescript-estree": "catalog:",
|
||||
"@typescript-eslint/utils": "catalog:",
|
||||
"fastest-levenshtein": "catalog:"
|
||||
|
||||
+30
-3
@@ -26,6 +26,21 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'major-only string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'major.minor string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'two-digit minor string api version is allowed',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.10", "nodes": ["dist/x.js"] } }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file is ignored',
|
||||
filename: 'some-config.json',
|
||||
@@ -77,10 +92,22 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
|
||||
errors: [{ messageId: 'missingNodesApiVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a string',
|
||||
name: 'n8nNodesApiVersion is a non-numeric string',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }],
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "v3", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: 'v3' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a string with a patch part',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1.0", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '3.1.0' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is a zero-major string',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "0.1", "nodes": ["dist/x.js"] } }',
|
||||
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0.1' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8nNodesApiVersion is zero',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
|
||||
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
@@ -34,9 +35,9 @@ export const N8nObjectValidationRule = createRule<[], MessageIds>({
|
||||
wrongLocationApiVersion:
|
||||
'"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
||||
missingNodesApiVersion:
|
||||
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).',
|
||||
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer, or a "<major>" or "<major>.<minor>" string).',
|
||||
invalidNodesApiVersion:
|
||||
'"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.',
|
||||
'"n8n.n8nNodesApiVersion" must be a positive integer, or a "<major>" or "<major>.<minor>" string, got {{ value }}.',
|
||||
missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
|
||||
n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
|
||||
emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
|
||||
@@ -108,7 +109,7 @@ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpressi
|
||||
}
|
||||
|
||||
const valueNode = apiVersionProp.value;
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || parseNodesApiLevel(valueNode.value) === null) {
|
||||
context.report({
|
||||
node: apiVersionProp,
|
||||
messageId: 'invalidNodesApiVersion',
|
||||
@@ -194,7 +195,3 @@ function validatePathArray(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
@@ -41,13 +41,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/constants": "workspace:*",
|
||||
"nanoid": "catalog:"
|
||||
"nanoid": "catalog:",
|
||||
"semver": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/oxlint-config": "workspace:*",
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "catalog:frontend",
|
||||
"@types/semver": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"oxlint-tsgolint": "catalog:",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { formatNodesApiLevel, parseNodesApiLevel } from './nodes-api-level';
|
||||
|
||||
describe('parseNodesApiLevel', () => {
|
||||
it.each([
|
||||
['3.1', { major: 3, minor: 1 }],
|
||||
['3.0', { major: 3, minor: 0 }],
|
||||
['3', { major: 3, minor: 0 }],
|
||||
['3.10', { major: 3, minor: 10 }],
|
||||
])('parses the string %p as %p', (value, expected) => {
|
||||
expect(parseNodesApiLevel(value)).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, { major: 1, minor: 0 }],
|
||||
[3, { major: 3, minor: 0 }],
|
||||
])('reads the integer %p as %p', (value, expected) => {
|
||||
expect(parseNodesApiLevel(value)).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
2.5,
|
||||
3.1,
|
||||
'0.0',
|
||||
'0.1',
|
||||
'01.1',
|
||||
'03',
|
||||
'3.01',
|
||||
'3.',
|
||||
'.1',
|
||||
' 3.2 ',
|
||||
'3.1.2',
|
||||
'v3',
|
||||
'',
|
||||
null,
|
||||
NaN,
|
||||
Infinity,
|
||||
true,
|
||||
{},
|
||||
])('rejects %p', (value) => {
|
||||
expect(parseNodesApiLevel(value)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([2 ** 53, '9007199254740992', '1.9007199254740992'])(
|
||||
'rejects the level %p above the safe integer range',
|
||||
(value) => {
|
||||
expect(parseNodesApiLevel(value)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('formatNodesApiLevel', () => {
|
||||
it('writes major and minor', () => {
|
||||
expect(formatNodesApiLevel(parseNodesApiLevel('3.0')!)).toBe('3.0');
|
||||
expect(formatNodesApiLevel(parseNodesApiLevel('3.10')!)).toBe('3.10');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { coerce, type SemVer } from 'semver';
|
||||
|
||||
// A float is rejected, since an unquoted `3.10` in package.json is read as
|
||||
// the number `3.1`. The input must print back from its coerced version as
|
||||
// `<major>` or `<major>.<minor>`.
|
||||
export function parseNodesApiLevel(value: unknown): SemVer | null {
|
||||
const text = typeof value === 'number' && Number.isInteger(value) ? String(value) : value;
|
||||
if (typeof text !== 'string') return null;
|
||||
|
||||
const version = coerce(text);
|
||||
if (version === null || version.major < 1) return null;
|
||||
if (text !== `${version.major}` && text !== `${version.major}.${version.minor}`) return null;
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
export function formatNodesApiLevel({ major, minor }: SemVer): string {
|
||||
return `${major}.${minor}`;
|
||||
}
|
||||
@@ -9,9 +9,9 @@ export class IncompatibleNodesApiVersionError extends ResponseError {
|
||||
message: string,
|
||||
readonly meta: {
|
||||
/** API version the package requires, or `null` if the declared value is malformed. */
|
||||
requiredNodesApiVersion: number | null;
|
||||
requiredNodesApiVersion: string | null;
|
||||
/** Node API version this runtime supports. */
|
||||
supportedNodesApiVersion: number;
|
||||
supportedNodesApiVersion: string;
|
||||
},
|
||||
cause?: unknown,
|
||||
) {
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { Container } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
|
||||
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'path';
|
||||
@@ -305,9 +305,10 @@ describe('node API compatibility at startup', () => {
|
||||
});
|
||||
|
||||
test('boots with an incompatible package on disk and registers no loader for it', async () => {
|
||||
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||
writePackage('n8n-nodes-future', {
|
||||
nodes: ['dist/nodes/Future.node.js'],
|
||||
n8nNodesApiVersion: N8N_NODES_API_VERSION + 1,
|
||||
n8nNodesApiVersion: `${major}.${minor + 1}`,
|
||||
});
|
||||
writePackage('n8n-nodes-good');
|
||||
|
||||
|
||||
+6
-6
@@ -310,7 +310,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||
communityPackagesService.installPackage.mockRejectedValue(
|
||||
new IncompatibleNodesApiVersionError(
|
||||
'This community node requires n8n node API version 3, but this instance supports up to 1.',
|
||||
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
||||
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -318,7 +318,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
httpStatusCode: 400,
|
||||
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
||||
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||
message:
|
||||
'This community node requires n8n node API version 3, but this instance supports up to 1.',
|
||||
});
|
||||
@@ -666,7 +666,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||
communityPackagesService.updatePackage.mockRejectedValue(
|
||||
new IncompatibleNodesApiVersionError(
|
||||
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
||||
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
||||
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -678,7 +678,7 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
httpStatusCode: 400,
|
||||
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
||||
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -691,8 +691,8 @@ describe('CommunityPackagesLifecycleService', () => {
|
||||
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage('1.0.0'));
|
||||
communityPackagesService.updatePackage.mockRejectedValue(
|
||||
new IncompatibleNodesApiVersionError('Not compatible', {
|
||||
requiredNodesApiVersion: 3,
|
||||
supportedNodesApiVersion: 1,
|
||||
requiredNodesApiVersion: '3',
|
||||
supportedNodesApiVersion: '1',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
+9
-5
@@ -3,6 +3,7 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
|
||||
import { N8N_NODES_API_VERSION } from '@n8n/constants';
|
||||
import { parseNodesApiLevel } from 'n8n-workflow';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
@@ -34,6 +35,9 @@ vi.mock('../npm-utils', async () => ({
|
||||
const PACKAGE_NAME = 'n8n-nodes-test';
|
||||
const TARBALL_NAME = `${PACKAGE_NAME}-2.0.0.tgz`;
|
||||
|
||||
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
|
||||
|
||||
/**
|
||||
* Exercises the install rollback against a real filesystem rather than mocked `fs`
|
||||
* calls. The bug being guarded here is a directory that is deleted with no backup
|
||||
@@ -264,7 +268,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||
};
|
||||
|
||||
test('rejects an update and leaves directory, ledger, and database unchanged', async () => {
|
||||
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
|
||||
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
|
||||
"isn't compatible with your version of n8n",
|
||||
);
|
||||
|
||||
@@ -284,7 +288,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||
});
|
||||
|
||||
test('rejects an update with a malformed declared version', async () => {
|
||||
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
|
||||
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
|
||||
|
||||
expect(existsSync(markerPath())).toBe(true);
|
||||
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
|
||||
@@ -314,7 +318,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||
downloadedPackageJson = {
|
||||
name: PACKAGE_NAME,
|
||||
version: '2.0.0',
|
||||
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
|
||||
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
|
||||
};
|
||||
|
||||
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow(
|
||||
@@ -334,7 +338,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||
downloadedPackageJson = {
|
||||
name: PACKAGE_NAME,
|
||||
version: '2.0.0',
|
||||
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
|
||||
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
|
||||
};
|
||||
// The follower resolves the version to install from the leader's database record.
|
||||
installedPackageRepository.findOne.mockResolvedValue(
|
||||
@@ -359,7 +363,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
|
||||
packageName: PACKAGE_NAME,
|
||||
reason: expect.stringContaining("isn't compatible with your version of n8n"),
|
||||
// The operator log names both versions, unlike the user-facing message.
|
||||
requiredNodesApiVersion: N8N_NODES_API_VERSION + 1,
|
||||
requiredNodesApiVersion: oneMinorAboveSupported,
|
||||
supportedNodesApiVersion: N8N_NODES_API_VERSION,
|
||||
}),
|
||||
);
|
||||
|
||||
+22
-5
@@ -3,7 +3,11 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
|
||||
import { mockInstance, randomName } from '@n8n/backend-test-utils';
|
||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
|
||||
import { N8N_NODES_API_VERSION, type PublicInstalledPackage } from 'n8n-workflow';
|
||||
import {
|
||||
N8N_NODES_API_VERSION,
|
||||
parseNodesApiLevel,
|
||||
type PublicInstalledPackage,
|
||||
} from 'n8n-workflow';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path, { join } from 'node:path';
|
||||
@@ -52,6 +56,9 @@ const execMock: typeof execFile = ((...args) => {
|
||||
|
||||
vi.mocked(execFile).mockImplementation(execMock);
|
||||
|
||||
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
|
||||
|
||||
describe('CommunityPackagesService', () => {
|
||||
const license = mock<License>();
|
||||
const configDefaults = {
|
||||
@@ -675,7 +682,7 @@ describe('CommunityPackagesService', () => {
|
||||
test('should reject the update when the package requires a newer node API version', async () => {
|
||||
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
||||
|
||||
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
|
||||
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
|
||||
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
||||
);
|
||||
|
||||
@@ -687,7 +694,7 @@ describe('CommunityPackagesService', () => {
|
||||
test('should reject the update when the declared node API version is malformed', async () => {
|
||||
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
||||
|
||||
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
|
||||
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
|
||||
|
||||
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
|
||||
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
|
||||
@@ -701,6 +708,16 @@ describe('CommunityPackagesService', () => {
|
||||
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
|
||||
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should update to a package that declares the supported level with an explicit minor', async () => {
|
||||
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
||||
|
||||
await expect(updateToIncompatible(`${supportedMajor}.${supportedMinor}`)).resolves.toBe(
|
||||
packageAfterUpdate,
|
||||
);
|
||||
|
||||
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
test('should remove the package.json dependency when a fresh install fails', async () => {
|
||||
@@ -1418,7 +1435,7 @@ describe('CommunityPackagesService', () => {
|
||||
JSON.stringify({
|
||||
name: 'package-1',
|
||||
version: '1.0.0',
|
||||
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
|
||||
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1438,7 +1455,7 @@ describe('CommunityPackagesService', () => {
|
||||
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
|
||||
config.reinstallMissing = true;
|
||||
vi.mocked(readFile).mockResolvedValue(
|
||||
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: '3' } }),
|
||||
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: 3.1 } }),
|
||||
);
|
||||
|
||||
await communityPackagesService.checkForMissingPackages();
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function getCommunityNodeTypes(
|
||||
environment: 'staging' | 'production',
|
||||
qs: { filters?: StrapiFilters; fields?: string[] } = {},
|
||||
maxAiNodeSdk: number,
|
||||
maxN8nNodesApiVersion: number,
|
||||
maxN8nNodesApiVersion: string,
|
||||
): Promise<StrapiCommunityNodeType[]> {
|
||||
const url = getUrl(environment);
|
||||
const params = {
|
||||
@@ -78,7 +78,7 @@ export async function getCommunityNodeTypes(
|
||||
export async function getCommunityNodesMetadata(
|
||||
environment: 'staging' | 'production',
|
||||
maxAiNodeSdk: number,
|
||||
maxN8nNodesApiVersion: number,
|
||||
maxN8nNodesApiVersion: string,
|
||||
): Promise<CommunityNodesMetadata[]> {
|
||||
const url = getUrl(environment);
|
||||
const params = {
|
||||
|
||||
@@ -40,7 +40,7 @@ export class CommunityPackagesConfig {
|
||||
readonly aiNodeSdkVersion: number = AI_NODE_SDK_VERSION;
|
||||
|
||||
/** Highest community node API version this instance supports, sent to Strapi API */
|
||||
readonly nodesApiVersion: number = N8N_NODES_API_VERSION;
|
||||
readonly nodesApiVersion: string = N8N_NODES_API_VERSION;
|
||||
|
||||
@Env('N8N_DISABLED_MODULES')
|
||||
private disabledModules: CommaSeparatedStringArray<string> = [];
|
||||
|
||||
@@ -304,14 +304,17 @@ export class CommunityPackagesService {
|
||||
const check = checkNodesApiVersion(packageJson);
|
||||
if (check.compatible) return;
|
||||
|
||||
const isMalformed = check.reason === 'malformed';
|
||||
if (check.reason === 'malformed') {
|
||||
throw new IncompatibleNodesApiVersionError(
|
||||
`This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`,
|
||||
{ requiredNodesApiVersion: null, supportedNodesApiVersion: N8N_NODES_API_VERSION },
|
||||
);
|
||||
}
|
||||
|
||||
throw new IncompatibleNodesApiVersionError(
|
||||
isMalformed
|
||||
? `This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`
|
||||
: "This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
||||
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
|
||||
{
|
||||
requiredNodesApiVersion: isMalformed ? null : Number(check.declared),
|
||||
requiredNodesApiVersion: check.required,
|
||||
supportedNodesApiVersion: N8N_NODES_API_VERSION,
|
||||
},
|
||||
);
|
||||
@@ -370,7 +373,7 @@ export class CommunityPackagesService {
|
||||
const requirement =
|
||||
apiVersionCheck.reason === 'malformed'
|
||||
? `an invalid n8nNodesApiVersion (${JSON.stringify(apiVersionCheck.declared)})`
|
||||
: `node API version ${String(apiVersionCheck.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
|
||||
: `node API version ${apiVersionCheck.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
|
||||
this.logger.warn(
|
||||
`Not reinstalling package "${installedPackage.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`,
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ type PaginationRequestParams = {
|
||||
pageSize: number;
|
||||
};
|
||||
maxAiNodeSdk?: number;
|
||||
maxN8nNodesApiVersion?: number;
|
||||
maxN8nNodesApiVersion?: string;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
|
||||
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -26,6 +26,9 @@ describe('scanDirectoryForPackages (real filesystem)', () => {
|
||||
rmSync(nodeModulesDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||
const unsupportedLevel = `${major}.${minor + 1}`;
|
||||
|
||||
const writePackage = (name: string, n8n?: object) => {
|
||||
const dir = path.join(nodeModulesDir, name);
|
||||
mkdirSync(dir);
|
||||
@@ -93,7 +96,7 @@ describe('scanDirectoryForPackages (real filesystem)', () => {
|
||||
it('registers no loader for a package requiring an unsupported node API version', async () => {
|
||||
writePackage('n8n-nodes-future', {
|
||||
nodes: ['dist/nodes/Future.node.js'],
|
||||
n8nNodesApiVersion: N8N_NODES_API_VERSION + 1,
|
||||
n8nNodesApiVersion: unsupportedLevel,
|
||||
});
|
||||
writePackage('n8n-nodes-good');
|
||||
|
||||
@@ -102,7 +105,7 @@ describe('scanDirectoryForPackages (real filesystem)', () => {
|
||||
expect(loaders.map((loader) => loader.packageName)).toEqual(['n8n-nodes-good']);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('n8n-nodes-future'));
|
||||
const [message] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(message).toContain(`node API version ${N8N_NODES_API_VERSION + 1}`);
|
||||
expect(message).toContain(`node API version ${unsupportedLevel}`);
|
||||
expect(message).toContain(`supports up to ${N8N_NODES_API_VERSION}`);
|
||||
expect(message).toContain('Upgrade n8n');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// eslint-disable-next-line import-x/order
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
|
||||
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
|
||||
import * as fs from 'node:fs';
|
||||
import type * as fsPromises from 'node:fs/promises';
|
||||
|
||||
@@ -36,6 +36,9 @@ describe('scanDirectoryForPackages', () => {
|
||||
const firecrawlPackageJson = (n8n?: object) =>
|
||||
JSON.stringify({ name: '@mendable/n8n-nodes-firecrawl', version: '2.1.2', ...{ n8n } });
|
||||
|
||||
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||
const unsupportedLevel = `${major}.${minor + 1}`;
|
||||
|
||||
const enoent = (file: string): NodeJS.ErrnoException => {
|
||||
const error: NodeJS.ErrnoException = new Error(
|
||||
`ENOENT: no such file or directory, open '${file}'`,
|
||||
@@ -116,7 +119,7 @@ describe('scanDirectoryForPackages', () => {
|
||||
});
|
||||
|
||||
it('skips a package requiring an unsupported node API version and keeps compatible ones', async () => {
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 }));
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: unsupportedLevel }));
|
||||
|
||||
const loaders = await scanDirectoryForPackages(nodeModulesDir);
|
||||
|
||||
@@ -127,7 +130,7 @@ describe('scanDirectoryForPackages', () => {
|
||||
});
|
||||
|
||||
it('returns a loader for a package declaring a supported node API version', async () => {
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: 1 }));
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: '1.0' }));
|
||||
|
||||
const loaders = await scanDirectoryForPackages(nodeModulesDir);
|
||||
|
||||
@@ -144,7 +147,7 @@ describe('scanDirectoryForPackages', () => {
|
||||
});
|
||||
|
||||
it('logs name, declared version, supported version, and remediation for an unsupported package', async () => {
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 }));
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: unsupportedLevel }));
|
||||
|
||||
await scanDirectoryForPackages(nodeModulesDir);
|
||||
|
||||
@@ -152,13 +155,13 @@ describe('scanDirectoryForPackages', () => {
|
||||
expect.stringContaining('@mendable/n8n-nodes-firecrawl'),
|
||||
);
|
||||
const [message] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(message).toContain(`node API version ${N8N_NODES_API_VERSION + 1}`);
|
||||
expect(message).toContain(`node API version ${unsupportedLevel}`);
|
||||
expect(message).toContain(`supports up to ${N8N_NODES_API_VERSION}`);
|
||||
expect(message).toContain('Upgrade n8n');
|
||||
});
|
||||
|
||||
it('skips a package with a malformed node API version', async () => {
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: '3' }));
|
||||
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: 3.1 }));
|
||||
|
||||
const loaders = await scanDirectoryForPackages(nodeModulesDir);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function scanDirectoryForPackages(
|
||||
const requirement =
|
||||
check.reason === 'malformed'
|
||||
? `an invalid n8nNodesApiVersion (${JSON.stringify(check.declared)})`
|
||||
: `node API version ${String(check.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
|
||||
: `node API version ${check.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
|
||||
logger.warn(
|
||||
`Skipping package "${loader.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,8 @@ export namespace n8n {
|
||||
n8n?: {
|
||||
credentials?: string[];
|
||||
nodes?: string[];
|
||||
n8nNodesApiVersion?: number;
|
||||
/** A positive integer or `"<major>.<minor>"`, as declared by the package. */
|
||||
n8nNodesApiVersion?: number | string;
|
||||
};
|
||||
author?: {
|
||||
name?: string;
|
||||
|
||||
+3
-3
@@ -52,13 +52,13 @@ describe('isCommunityPackageUpdateAvailable', () => {
|
||||
});
|
||||
|
||||
describe('isNodesApiVersionError', () => {
|
||||
const incompatibleNodesApiVersionError = (requiredNodesApiVersion: number | null): unknown => ({
|
||||
const incompatibleNodesApiVersionError = (requiredNodesApiVersion: string | null): unknown => ({
|
||||
httpStatusCode: 400,
|
||||
meta: { requiredNodesApiVersion, supportedNodesApiVersion: 1 },
|
||||
meta: { requiredNodesApiVersion, supportedNodesApiVersion: '1' },
|
||||
});
|
||||
|
||||
it('should match the error metadata, not the message', () => {
|
||||
expect(isNodesApiVersionError(incompatibleNodesApiVersionError(3))).toBe(true);
|
||||
expect(isNodesApiVersionError(incompatibleNodesApiVersionError('3.1'))).toBe(true);
|
||||
// Malformed declared values carry `null` and must be recognized too.
|
||||
expect(isNodesApiVersionError(incompatibleNodesApiVersionError(null))).toBe(true);
|
||||
// The same copy without the metadata is a generic error.
|
||||
|
||||
+3
-3
@@ -43,9 +43,9 @@ export function isCommunityPackageUpdateAvailable({
|
||||
interface IncompatibleNodesApiVersionErrorResponse {
|
||||
httpStatusCode: number;
|
||||
meta: {
|
||||
/** API version the package requires, or `null` if the declared value is malformed. */
|
||||
requiredNodesApiVersion: number | null;
|
||||
supportedNodesApiVersion: number;
|
||||
/** `null` if the declared value is malformed. */
|
||||
requiredNodesApiVersion: string | null;
|
||||
supportedNodesApiVersion: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -400,7 +400,7 @@ describe('useInstallNode', () => {
|
||||
new Error('This community node requires n8n node API version 3.'),
|
||||
{
|
||||
httpStatusCode: 400,
|
||||
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
|
||||
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
|
||||
},
|
||||
);
|
||||
vi.mocked(communityNodesStore.installPackage).mockRejectedValue(error);
|
||||
|
||||
+4
-4
@@ -81,9 +81,9 @@ test.describe(
|
||||
expect(response.status()).toBe(400);
|
||||
const rejection = await api.communityPackages.readRejection(response);
|
||||
expect(rejection.message).toContain("isn't compatible with your version of n8n");
|
||||
expect(String(rejection.meta.requiredNodesApiVersion)).toBe('3');
|
||||
expect(String(rejection.meta.supportedNodesApiVersion)).toBe(
|
||||
String(supportedNodesApiVersion),
|
||||
expect(rejection.meta.requiredNodesApiVersion).toBe('3.0');
|
||||
expect(String(rejection.meta.supportedNodesApiVersion)).toMatch(
|
||||
new RegExp(`^${supportedNodesApiVersion}(\\.\\d+)?$`),
|
||||
);
|
||||
await expectAbsent(api, packageDisk, v3);
|
||||
});
|
||||
@@ -109,7 +109,7 @@ test.describe(
|
||||
const response = await api.communityPackages.update(legacyV3.name, legacyV3.version);
|
||||
expect(response.status()).toBe(400);
|
||||
const rejection = await api.communityPackages.readRejection(response);
|
||||
expect(String(rejection.meta.requiredNodesApiVersion)).toBe('3');
|
||||
expect(rejection.meta.requiredNodesApiVersion).toBe('3.0');
|
||||
|
||||
await expectInstalled(api, packageDisk, legacy);
|
||||
const state = await packageDisk.stateOf(legacyV3.name);
|
||||
|
||||
@@ -1,54 +1,53 @@
|
||||
import { N8N_NODES_API_VERSION } from '@n8n/constants';
|
||||
import { formatNodesApiLevel, parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
|
||||
|
||||
export { N8N_NODES_API_VERSION };
|
||||
export { N8N_NODES_API_VERSION, formatNodesApiLevel, parseNodesApiLevel };
|
||||
|
||||
/** Minimal package.json shape needed to check node API compatibility. */
|
||||
export interface NodesApiVersionPackageJson {
|
||||
n8n?: {
|
||||
/**
|
||||
* Node-authoring API level the package requires. Absent in legacy
|
||||
* packages, which are treated as requiring level 1.
|
||||
*/
|
||||
n8nNodesApiVersion?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type NodesApiVersionCheck =
|
||||
| { compatible: true }
|
||||
| {
|
||||
compatible: true;
|
||||
/** Effective required API level; 1 for packages without metadata. */
|
||||
version: number;
|
||||
compatible: false;
|
||||
reason: 'malformed';
|
||||
declared: unknown;
|
||||
}
|
||||
| {
|
||||
compatible: false;
|
||||
reason: 'malformed' | 'unsupported';
|
||||
/** The declared value, as read from package.json. */
|
||||
reason: 'unsupported';
|
||||
declared: unknown;
|
||||
/** As `<major>.<minor>`, for messages and metadata. */
|
||||
required: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a community package's declared node-authoring API level and check it
|
||||
* against the level this runtime supports (`required <= N8N_NODES_API_VERSION`).
|
||||
*
|
||||
* Missing `n8n.n8nNodesApiVersion` means a legacy package and resolves to
|
||||
* level 1. Malformed values (non-integer, non-positive, non-number) are
|
||||
* reported as incompatible — the runtime cannot distinguish an old package
|
||||
* from a corrupt or hostile one if both fall back to legacy.
|
||||
*/
|
||||
function supportedLevel() {
|
||||
const level = parseNodesApiLevel(N8N_NODES_API_VERSION);
|
||||
if (level === null)
|
||||
throw new Error(`N8N_NODES_API_VERSION is not a level: ${N8N_NODES_API_VERSION}`);
|
||||
return level;
|
||||
}
|
||||
|
||||
// A missing declaration is a legacy package at level 1. A malformed one is
|
||||
// incompatible: the runtime cannot tell an old package from a corrupt one.
|
||||
export function checkNodesApiVersion(pkgJson: NodesApiVersionPackageJson): NodesApiVersionCheck {
|
||||
const declared = pkgJson?.n8n?.n8nNodesApiVersion;
|
||||
|
||||
if (declared === undefined) {
|
||||
return { compatible: true, version: 1 };
|
||||
}
|
||||
if (declared === undefined) return { compatible: true };
|
||||
|
||||
if (typeof declared !== 'number' || !Number.isInteger(declared) || declared < 1) {
|
||||
return { compatible: false, reason: 'malformed', declared };
|
||||
}
|
||||
const required = parseNodesApiLevel(declared);
|
||||
if (required === null) return { compatible: false, reason: 'malformed', declared };
|
||||
|
||||
if (declared <= N8N_NODES_API_VERSION) {
|
||||
return { compatible: true, version: declared };
|
||||
}
|
||||
if (required.compare(supportedLevel()) <= 0) return { compatible: true };
|
||||
|
||||
return { compatible: false, reason: 'unsupported', declared };
|
||||
return {
|
||||
compatible: false,
|
||||
reason: 'unsupported',
|
||||
declared,
|
||||
required: formatNodesApiLevel(required),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,49 +1,82 @@
|
||||
import { N8N_NODES_API_VERSION, checkNodesApiVersion } from '../src/nodes-api-version';
|
||||
import {
|
||||
N8N_NODES_API_VERSION,
|
||||
checkNodesApiVersion,
|
||||
formatNodesApiLevel,
|
||||
parseNodesApiLevel,
|
||||
} from '../src/nodes-api-version';
|
||||
|
||||
const pkg = (n8nNodesApiVersion?: unknown) => ({
|
||||
n8n: n8nNodesApiVersion === undefined ? {} : { n8nNodesApiVersion },
|
||||
});
|
||||
|
||||
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
|
||||
|
||||
describe('N8N_NODES_API_VERSION', () => {
|
||||
it('is a level written as major.minor', () => {
|
||||
expect(N8N_NODES_API_VERSION).toBe(
|
||||
formatNodesApiLevel(parseNodesApiLevel(N8N_NODES_API_VERSION)!),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkNodesApiVersion', () => {
|
||||
it('treats a missing n8n section as legacy level 1', () => {
|
||||
expect(checkNodesApiVersion({})).toEqual({ compatible: true, version: 1 });
|
||||
expect(checkNodesApiVersion({})).toEqual({ compatible: true });
|
||||
});
|
||||
|
||||
it('treats a missing n8nNodesApiVersion as legacy level 1', () => {
|
||||
expect(checkNodesApiVersion(pkg())).toEqual({ compatible: true, version: 1 });
|
||||
expect(checkNodesApiVersion(pkg())).toEqual({ compatible: true });
|
||||
});
|
||||
|
||||
// The rule under test is `required <= supported`. The expectations below
|
||||
// state that rule at its exact boundaries instead of pinning the
|
||||
// constant's value, so they survive a deliberate bump without edits.
|
||||
// They also fail if the constant degenerates (below 1 or non-integer).
|
||||
it('accepts the floor level 1', () => {
|
||||
expect(checkNodesApiVersion(pkg(1))).toEqual({ compatible: true, version: 1 });
|
||||
it('accepts the integer 1', () => {
|
||||
expect(checkNodesApiVersion(pkg(1))).toEqual({ compatible: true });
|
||||
});
|
||||
|
||||
it('accepts a package that requires exactly the supported level', () => {
|
||||
expect(checkNodesApiVersion(pkg(N8N_NODES_API_VERSION))).toEqual({
|
||||
it('accepts the supported level', () => {
|
||||
expect(checkNodesApiVersion(pkg(N8N_NODES_API_VERSION))).toEqual({ compatible: true });
|
||||
});
|
||||
|
||||
it('accepts a minor below the supported one', () => {
|
||||
if (supportedMinor === 0) return;
|
||||
expect(checkNodesApiVersion(pkg(`${supportedMajor}.${supportedMinor - 1}`))).toEqual({
|
||||
compatible: true,
|
||||
version: N8N_NODES_API_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a package that requires one level above the supported level', () => {
|
||||
const above = N8N_NODES_API_VERSION + 1;
|
||||
it('rejects one minor above the supported level', () => {
|
||||
const above = `${supportedMajor}.${supportedMinor + 1}`;
|
||||
expect(checkNodesApiVersion(pkg(above))).toEqual({
|
||||
compatible: false,
|
||||
reason: 'unsupported',
|
||||
declared: above,
|
||||
required: above,
|
||||
});
|
||||
});
|
||||
|
||||
for (const declared of ['3', 0, -1, 2.5, null, NaN, Infinity, true, {}]) {
|
||||
it(`rejects malformed value ${String(declared)}`, () => {
|
||||
it('rejects the next major at minor 0', () => {
|
||||
const above = `${supportedMajor + 1}.0`;
|
||||
expect(checkNodesApiVersion(pkg(above))).toEqual({
|
||||
compatible: false,
|
||||
reason: 'unsupported',
|
||||
declared: above,
|
||||
required: above,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an integer level as major.minor', () => {
|
||||
expect(checkNodesApiVersion(pkg(supportedMajor + 1))).toMatchObject({
|
||||
required: `${supportedMajor + 1}.0`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['3.1.0', 'three', 0, -1, 2.5, 3.1, null, NaN, true, {}, '9007199254740992'])(
|
||||
'rejects the malformed value %p',
|
||||
(declared) => {
|
||||
expect(checkNodesApiVersion(pkg(declared))).toEqual({
|
||||
compatible: false,
|
||||
reason: 'malformed',
|
||||
declared,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Generated
+9
@@ -2886,6 +2886,9 @@ importers:
|
||||
|
||||
packages/@n8n/eslint-plugin-community-nodes:
|
||||
dependencies:
|
||||
'@n8n/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
'@typescript-eslint/typescript-estree':
|
||||
specifier: 'catalog:'
|
||||
version: 8.70.0(@typescript/[email protected])([email protected])
|
||||
@@ -4589,6 +4592,9 @@ importers:
|
||||
nanoid:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.18
|
||||
semver:
|
||||
specifier: 7.7.3
|
||||
version: 7.7.3
|
||||
devDependencies:
|
||||
'@n8n/oxlint-config':
|
||||
specifier: workspace:*
|
||||
@@ -4602,6 +4608,9 @@ importers:
|
||||
'@testing-library/jest-dom':
|
||||
specifier: catalog:frontend
|
||||
version: 6.9.1
|
||||
'@types/semver':
|
||||
specifier: 'catalog:'
|
||||
version: 7.7.0
|
||||
fast-check:
|
||||
specifier: 'catalog:'
|
||||
version: 3.23.2
|
||||
|
||||
Reference in New Issue
Block a user