test: Add E2E coverage for the community node API version guard (no-changelog) (#39805)

This commit is contained in:
Benny Neugebauer
2026-10-02 09:23:26 +00:00
committed by GitHub
parent c95e34f97a
commit 7576de5f26
23 changed files with 806 additions and 7 deletions
@@ -16,6 +16,7 @@ export default [
'coverage/**/*',
'scripts/**/*',
'janitor.config.mjs',
'fixtures/community-packages/packages/**/*.js',
],
},
{
@@ -0,0 +1 @@
!packages/*/dist/
@@ -0,0 +1,40 @@
import { expect } from '../base';
import { importMarkerName, nodeType, type FixturePackage } from './fixture-packages';
import type { PackageDisk } from './index';
import type { n8nPage } from '../../pages/n8nPage';
import type { ApiHelpers } from '../../services/api-helper';
export const versionedName = (pkg: FixturePackage) => `${pkg.name}@${pkg.version}`;
export async function expectInstalled(api: ApiHelpers, disk: PackageDisk, pkg: FixturePackage) {
await expect(api.communityPackages.find(pkg.name)).resolves.toMatchObject({
installedVersion: pkg.version,
});
await expect(api.communityPackages.nodeTypeNames()).resolves.toContain(nodeType(pkg));
const state = await disk.stateOf(pkg.name);
expect(state.installedVersion).toBe(pkg.version);
expect(state.importMarkers).toContain(importMarkerName(pkg));
}
export async function expectAbsent(api: ApiHelpers, disk: PackageDisk, pkg: FixturePackage) {
await expect(api.communityPackages.find(pkg.name)).resolves.toBeUndefined();
await expect(api.communityPackages.nodeTypeNames()).resolves.not.toContain(nodeType(pkg));
await expect(disk.stateOf(pkg.name)).resolves.toEqual({
installedVersion: undefined,
importMarkers: [],
});
}
export async function expectRejectedOnSettingsPage(
{ n8n, api, packageDisk }: { n8n: n8nPage; api: ApiHelpers; packageDisk: PackageDisk },
pkg: FixturePackage,
error: RegExp,
) {
await n8n.navigate.toCommunityNodes();
await n8n.communityNodes.submitInstall(versionedName(pkg));
await expect(
n8n.notifications.getNotificationByTitle('Package not compatible with this n8n version'),
).toBeVisible();
await expect(n8n.communityNodes.getInstallModalError(error)).toBeVisible();
await expectAbsent(api, packageDisk, pkg);
}
@@ -0,0 +1,59 @@
import { join } from 'node:path';
/** Complete packages under `packages/`; each ships `dist/CompatProbe.node.js`, which writes an import marker when required. */
const PACKAGES_DIR = join(__dirname, 'packages');
export const IMPORT_MARKER_DIR = '.n8n/community-package-imports';
export type Requirement = { kind: 'level'; level: number } | { kind: 'malformed' };
export interface FixturePackage {
name: string;
version: string;
directory: string;
nodesApiVersion: number | string | undefined;
requirement: Requirement;
}
const fixturePackage = (
name: string,
version: string,
nodesApiVersion: number | string | undefined,
requirement: Requirement,
): FixturePackage => ({
name,
version,
directory: join(PACKAGES_DIR, `${name}-${version}`),
nodesApiVersion,
requirement,
});
export const INSTALLABLE_PACKAGES = {
legacy: fixturePackage('n8n-nodes-compat-legacy', '1.0.0', undefined, {
kind: 'level',
level: 1,
}),
v1: fixturePackage('n8n-nodes-compat-v1', '1.0.0', 1, { kind: 'level', level: 1 }),
v3: fixturePackage('n8n-nodes-compat-v3', '1.0.0', 3, { kind: 'level', level: 3 }),
malformed: fixturePackage('n8n-nodes-compat-malformed', '1.0.0', 'not-a-level', {
kind: 'malformed',
}),
} as const satisfies Record<string, FixturePackage>;
export const LEGACY_PACKAGE_V3_UPDATE = fixturePackage('n8n-nodes-compat-legacy', '2.0.0', 3, {
kind: 'level',
level: 3,
});
/** Publish order matters: the last version published becomes `latest`. */
export const PUBLISHED_PACKAGES: readonly FixturePackage[] = [
INSTALLABLE_PACKAGES.legacy,
INSTALLABLE_PACKAGES.v1,
INSTALLABLE_PACKAGES.v3,
INSTALLABLE_PACKAGES.malformed,
LEGACY_PACKAGE_V3_UPDATE,
];
export const importMarkerName = (pkg: FixturePackage): string => `${pkg.name}@${pkg.version}`;
export const nodeType = (pkg: FixturePackage): string => `${pkg.name}.compatProbe`;
@@ -0,0 +1,81 @@
import { test as base } from '../base';
import { IMPORT_MARKER_DIR, PUBLISHED_PACKAGES, type FixturePackage } from './fixture-packages';
export * from './fixture-packages';
export { expect } from '../base';
export interface PackageDiskState {
/** `version` of `~/.n8n/nodes/node_modules/<name>/package.json`, `undefined` when not on disk. */
installedVersion: string | undefined;
/** Import markers (`<name>@<version>`) the package's node module wrote when required. */
importMarkers: string[];
}
export interface PackageDisk {
stateOf(packageName: string): Promise<PackageDiskState>;
}
type Fixtures = {
/** Publishes every fixture package into the stack's registry, once per worker, before any test runs. */
publishedPackages: readonly FixturePackage[];
/** Node API level the instance supports: 2.x runs level 1, 3.x runs level 3. */
supportedNodesApiVersion: number;
/** What the package left on the instance's disk; follows the main container across a restart. */
packageDisk: PackageDisk;
};
/** `test` for specs that install real community packages; needs the `community-packages` capability. */
export const test = base.extend<
Pick<Fixtures, 'supportedNodesApiVersion' | 'packageDisk'>,
Pick<Fixtures, 'publishedPackages'>
>({
publishedPackages: [
async ({ n8nContainer }, use) => {
if (n8nContainer) {
for (const pkg of PUBLISHED_PACKAGES) {
await n8nContainer.services.npmRegistry.publishDirectory(pkg.directory);
}
}
await use(PUBLISHED_PACKAGES);
},
{ scope: 'worker', auto: true },
],
supportedNodesApiVersion: async ({ api }, use) => {
const response = await api.request.get('/rest/settings');
const { data } = (await response.json()) as { data: { versionCli: string } };
const major = Number(data.versionCli.split('.')[0]);
await use(major < 3 ? 1 : major);
},
packageDisk: async ({ n8nContainer }, use) => {
const main = () => {
const [container] = n8nContainer.findContainers(/-n8n(-main-1)?$/);
if (!container) throw new Error('no n8n main container in this stack');
return container;
};
const read = async (command: string[]) => {
const { output, exitCode } = await main().exec(command);
return exitCode === 0 ? output.trim() : undefined;
};
const userFolder = async () =>
(await read(['printenv', 'N8N_USER_FOLDER'])) ?? (await read(['printenv', 'HOME']));
await use({
async stateOf(packageName) {
const home = await userFolder();
const manifest = await read([
'cat',
`${home}/.n8n/nodes/node_modules/${packageName}/package.json`,
]);
const markers = await read(['ls', '-1', `${home}/${IMPORT_MARKER_DIR}`]);
return {
installedVersion: manifest && (JSON.parse(manifest) as { version: string }).version,
importMarkers: (markers?.split('\n') ?? []).filter((marker) =>
marker.startsWith(`${packageName}@`),
),
};
},
});
},
});
@@ -0,0 +1,32 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { name, version } = require('../package.json');
const userFolder = process.env.N8N_USER_FOLDER ?? process.env.HOME ?? process.cwd();
const markerDir = path.join(userFolder, '.n8n', 'community-package-imports');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, `${name}@${version}`), new Date().toISOString());
class CompatProbe {
constructor() {
this.description = {
displayName: `Compat Probe (${name}@${version})`,
name: 'compatProbe',
group: ['transform'],
version: 1,
description: 'Node API compatibility fixture',
defaults: { name: 'Compat Probe' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}
async execute() {
return [this.getInputData()];
}
}
module.exports = { CompatProbe };
@@ -0,0 +1,11 @@
{
"name": "n8n-nodes-compat-legacy",
"version": "1.0.0",
"description": "Compatibility fixture: no n8nNodesApiVersion, a legacy package",
"license": "MIT",
"keywords": ["n8n-community-node-package"],
"files": ["dist"],
"n8n": {
"nodes": ["dist/CompatProbe.node.js"]
}
}
@@ -0,0 +1,32 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { name, version } = require('../package.json');
const userFolder = process.env.N8N_USER_FOLDER ?? process.env.HOME ?? process.cwd();
const markerDir = path.join(userFolder, '.n8n', 'community-package-imports');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, `${name}@${version}`), new Date().toISOString());
class CompatProbe {
constructor() {
this.description = {
displayName: `Compat Probe (${name}@${version})`,
name: 'compatProbe',
group: ['transform'],
version: 1,
description: 'Node API compatibility fixture',
defaults: { name: 'Compat Probe' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}
async execute() {
return [this.getInputData()];
}
}
module.exports = { CompatProbe };
@@ -0,0 +1,12 @@
{
"name": "n8n-nodes-compat-legacy",
"version": "2.0.0",
"description": "Compatibility fixture: the legacy package moves to node API level 3",
"license": "MIT",
"keywords": ["n8n-community-node-package"],
"files": ["dist"],
"n8n": {
"n8nNodesApiVersion": 3,
"nodes": ["dist/CompatProbe.node.js"]
}
}
@@ -0,0 +1,32 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { name, version } = require('../package.json');
const userFolder = process.env.N8N_USER_FOLDER ?? process.env.HOME ?? process.cwd();
const markerDir = path.join(userFolder, '.n8n', 'community-package-imports');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, `${name}@${version}`), new Date().toISOString());
class CompatProbe {
constructor() {
this.description = {
displayName: `Compat Probe (${name}@${version})`,
name: 'compatProbe',
group: ['transform'],
version: 1,
description: 'Node API compatibility fixture',
defaults: { name: 'Compat Probe' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}
async execute() {
return [this.getInputData()];
}
}
module.exports = { CompatProbe };
@@ -0,0 +1,12 @@
{
"name": "n8n-nodes-compat-malformed",
"version": "1.0.0",
"description": "Compatibility fixture: n8nNodesApiVersion is not a level at all",
"license": "MIT",
"keywords": ["n8n-community-node-package"],
"files": ["dist"],
"n8n": {
"n8nNodesApiVersion": "not-a-level",
"nodes": ["dist/CompatProbe.node.js"]
}
}
@@ -0,0 +1,32 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { name, version } = require('../package.json');
const userFolder = process.env.N8N_USER_FOLDER ?? process.env.HOME ?? process.cwd();
const markerDir = path.join(userFolder, '.n8n', 'community-package-imports');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, `${name}@${version}`), new Date().toISOString());
class CompatProbe {
constructor() {
this.description = {
displayName: `Compat Probe (${name}@${version})`,
name: 'compatProbe',
group: ['transform'],
version: 1,
description: 'Node API compatibility fixture',
defaults: { name: 'Compat Probe' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}
async execute() {
return [this.getInputData()];
}
}
module.exports = { CompatProbe };
@@ -0,0 +1,12 @@
{
"name": "n8n-nodes-compat-v1",
"version": "1.0.0",
"description": "Compatibility fixture: explicit node API level 1",
"license": "MIT",
"keywords": ["n8n-community-node-package"],
"files": ["dist"],
"n8n": {
"n8nNodesApiVersion": 1,
"nodes": ["dist/CompatProbe.node.js"]
}
}
@@ -0,0 +1,32 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { name, version } = require('../package.json');
const userFolder = process.env.N8N_USER_FOLDER ?? process.env.HOME ?? process.cwd();
const markerDir = path.join(userFolder, '.n8n', 'community-package-imports');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, `${name}@${version}`), new Date().toISOString());
class CompatProbe {
constructor() {
this.description = {
displayName: `Compat Probe (${name}@${version})`,
name: 'compatProbe',
group: ['transform'],
version: 1,
description: 'Node API compatibility fixture',
defaults: { name: 'Compat Probe' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}
async execute() {
return [this.getInputData()];
}
}
module.exports = { CompatProbe };
@@ -0,0 +1,12 @@
{
"name": "n8n-nodes-compat-v3",
"version": "1.0.0",
"description": "Compatibility fixture: explicit node API level 3",
"license": "MIT",
"keywords": ["n8n-community-node-package"],
"files": ["dist"],
"n8n": {
"n8nNodesApiVersion": 3,
"nodes": ["dist/CompatProbe.node.js"]
}
}
@@ -12,6 +12,7 @@ export default defineConfig({
'coverage/**/*',
'scripts/**/*',
'janitor.config.mjs',
'fixtures/community-packages/packages/**/*.js',
],
rules: {
'playwright/missing-playwright-await': 'error',
@@ -18,6 +18,7 @@
"test:infrastructure": "playwright test --project='*:infrastructure'",
"test:encryption:upgrade": "playwright test --project='encryption:infrastructure' tests/infrastructure/encryption/upgrade-cycle.spec.ts",
"test:encryption:rotation": "playwright test --project='encryption:infrastructure' tests/infrastructure/encryption/rotation-cycle.spec.ts",
"test:community-packages": "playwright test --project='sqlite:e2e' --project='sqlite:infrastructure' community-nodes/node-api-version-guard community-packages/startup-guard",
"test:benchmark": "playwright test --project='benchmarking:infrastructure'",
"benchmark:sizing-matrix": "tsx scripts/sizing-matrix-aggregate.ts",
"test:container:sqlite": "playwright test --project='sqlite:*'",
@@ -19,6 +19,18 @@ export class CommunityNodesPage extends BasePage {
return this.page.getByTestId('empty-state');
}
getCommunityCard(packageName: string): Locator {
return this.getCommunityCards().filter({ hasText: packageName });
}
getInstallModalError(text: string | RegExp): Locator {
return this.getInstallModal().getByText(text);
}
getFailedToLoadIcon(packageName: string): Locator {
return this.getCommunityCard(packageName).locator('[data-icon="triangle-alert"]');
}
getInstallButton(): Locator {
// Try the empty state first, fallback to header install button
const emptyStateButton = this.getEmptyState().locator('button');
@@ -55,8 +67,11 @@ export class CommunityNodesPage extends BasePage {
return this.actionToggle.getAction('uninstall');
}
getUpdateButton(): Locator {
return this.getCommunityCards().first().getByRole('button', { name: 'Update' });
getUpdateButton(packageName?: string): Locator {
const card = packageName
? this.getCommunityCard(packageName)
: this.getCommunityCards().first();
return card.getByRole('button', { name: 'Update' });
}
getConfirmUpdateButton(): Locator {
@@ -92,8 +107,8 @@ export class CommunityNodesPage extends BasePage {
await this.getUninstallAction().click();
}
async clickUpdateButton(): Promise<void> {
await this.getUpdateButton().click();
async clickUpdateButton(packageName?: string): Promise<void> {
await this.getUpdateButton(packageName).click();
}
async clickConfirmUpdate(): Promise<void> {
@@ -105,18 +120,23 @@ export class CommunityNodesPage extends BasePage {
}
// Helper methods for common workflows
async installPackage(packageName: string): Promise<void> {
/** Fills and submits the install dialog without waiting for the result. */
async submitInstall(packageName: string): Promise<void> {
await this.clickInstallButton();
await this.fillPackageName(packageName);
await this.clickUserAgreementCheckbox();
await this.clickInstallPackageButton();
}
async installPackage(packageName: string): Promise<void> {
await this.submitInstall(packageName);
// Wait for install modal to close
await this.getInstallModal().waitFor({ state: 'hidden' });
}
async updatePackage(): Promise<void> {
await this.clickUpdateButton();
async updatePackage(packageName?: string): Promise<void> {
await this.clickUpdateButton(packageName);
await this.clickConfirmUpdate();
}
@@ -19,6 +19,7 @@ import {
import { TestError } from '../Types';
import { CredentialApiHelper } from './credential-api-helper';
import { AgentApiHelper } from './agent-api-helper';
import { CommunityPackagesApiHelper } from './community-packages-api-helper';
import { DynamicCredentialApiHelper } from './dynamic-credential-api-helper';
import { ExternalSecretsApiHelper } from './external-secrets-api-helper';
import { InstanceAiApiHelper } from './instance-ai-api-helper';
@@ -102,6 +103,7 @@ export class ApiHelpers {
nodeTypePolicies: TypePolicyApiHelper<'node'>;
credentialTypePolicies: TypePolicyApiHelper<'credential'>;
nodeParameters: NodeParameterApiHelper;
communityPackages: CommunityPackagesApiHelper;
publicApi: PublicApiHelper;
@@ -131,6 +133,7 @@ export class ApiHelpers {
this.nodeTypePolicies = new TypePolicyApiHelper(this, 'node');
this.credentialTypePolicies = new TypePolicyApiHelper(this, 'credential');
this.nodeParameters = new NodeParameterApiHelper(this);
this.communityPackages = new CommunityPackagesApiHelper(this);
this.publicApi = new PublicApiHelper(this);
}
@@ -0,0 +1,93 @@
import type { APIResponse } from '@playwright/test';
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
export interface InstalledCommunityPackage {
packageName: string;
installedVersion: string;
installedNodes: Array<{ name: string; type: string; latestVersion: number }>;
updateAvailable?: string;
failedLoading?: boolean;
}
/** Body of the 400 the node API version guard returns. */
export interface NodesApiVersionRejection {
message: string;
meta: {
requiredNodesApiVersion: number | string | null;
supportedNodesApiVersion: number | string;
};
}
/**
* Installs run `npm pack` and `npm install` in the container and call
* api.n8n.io for the package status, so they can outlast the default request
* timeout on a slow network.
*/
const INSTALL_TIMEOUT_MS = 90_000;
export class CommunityPackagesApiHelper {
constructor(private api: ApiHelpers) {}
async list(): Promise<InstalledCommunityPackage[]> {
const response = await this.api.request.get('/rest/community-packages');
if (!response.ok()) {
throw new TestError(`Failed to list community packages: ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
async find(packageName: string): Promise<InstalledCommunityPackage | undefined> {
return (await this.list()).find((pkg) => pkg.packageName === packageName);
}
/**
* Installs by npm spec (`name` or `name@version`) through the unverified
* path, exactly what the settings page sends. Returns the raw response so
* a test can assert a rejection.
*/
async install(spec: string): Promise<APIResponse> {
return await this.api.request.post('/rest/community-packages', {
data: { name: spec },
timeout: INSTALL_TIMEOUT_MS,
});
}
async update(packageName: string, version?: string): Promise<APIResponse> {
return await this.api.request.patch('/rest/community-packages', {
data: { name: packageName, version },
timeout: INSTALL_TIMEOUT_MS,
});
}
async uninstall(packageName: string): Promise<void> {
const response = await this.api.request.delete('/rest/community-packages', {
params: { name: packageName },
});
if (!response.ok()) {
throw new TestError(`Failed to uninstall ${packageName}: ${await response.text()}`);
}
}
async uninstallAll(): Promise<void> {
for (const pkg of await this.list()) {
await this.uninstall(pkg.packageName);
}
}
/** Names of every node type the instance currently serves. */
async nodeTypeNames(): Promise<string[]> {
const response = await this.api.request.get('/types/nodes.json');
if (!response.ok()) {
throw new TestError(`Failed to fetch node types: ${await response.text()}`);
}
const types = (await response.json()) as Array<{ name: string }>;
return types.map((type) => type.name);
}
async readRejection(response: APIResponse): Promise<NodesApiVersionRejection> {
return (await response.json()) as NodesApiVersionRejection;
}
}
@@ -0,0 +1,164 @@
import {
expect,
importMarkerName,
INSTALLABLE_PACKAGES,
LEGACY_PACKAGE_V3_UPDATE,
test,
} from '../../../../fixtures/community-packages';
import {
expectAbsent,
expectInstalled,
expectRejectedOnSettingsPage,
versionedName,
} from '../../../../fixtures/community-packages/assertions';
/**
* The node API compatibility guard, end to end: a real n8n installs real
* packages from a registry the suite seeds. A 2.x image supports level 1 and
* rejects level 3 packages, a 3.x image accepts them, and a malformed
* declaration is rejected everywhere.
*/
test.use({ capability: 'community-packages' });
const { legacy, v1, v3, malformed } = INSTALLABLE_PACKAGES;
const legacyV3 = LEGACY_PACKAGE_V3_UPDATE;
test.describe(
'Community node API version guard',
{ annotation: [{ type: 'owner', description: 'NODES' }] },
() => {
test.beforeEach(async ({ api }) => {
await api.enableFeature('communityNodes:customRegistry');
});
test.afterEach(async ({ api }) => {
await api.communityPackages.uninstallAll();
});
test('installs packages declaring n8nNodesApiVersion 1 or none', async ({
api,
packageDisk,
}) => {
for (const pkg of [legacy, v1]) {
const response = await api.communityPackages.install(versionedName(pkg));
expect(response.status()).toBe(200);
await expectInstalled(api, packageDisk, pkg);
}
});
test('rejects a malformed n8nNodesApiVersion', async ({ api, packageDisk }) => {
const response = await api.communityPackages.install(versionedName(malformed));
expect(response.status()).toBe(400);
const rejection = await api.communityPackages.readRejection(response);
expect(rejection.message).toContain('declares an invalid n8n node API version');
expect(rejection.meta.requiredNodesApiVersion).toBeNull();
await expectAbsent(api, packageDisk, malformed);
});
test('reports a malformed n8nNodesApiVersion on the settings page', async ({
n8n,
api,
packageDisk,
}) => {
await expectRejectedOnSettingsPage(
{ n8n, api, packageDisk },
malformed,
/declares an invalid n8n node API version/,
);
});
test.describe('on an instance below node API level 3', () => {
test.beforeEach(({ supportedNodesApiVersion }) => {
test.skip(supportedNodesApiVersion >= 3, 'this instance supports level 3');
});
test('rejects installing a level 3 package', async ({
api,
packageDisk,
supportedNodesApiVersion,
}) => {
const response = await api.communityPackages.install(versionedName(v3));
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),
);
await expectAbsent(api, packageDisk, v3);
});
test('explains the rejected install on the settings page', async ({
n8n,
api,
packageDisk,
}) => {
await expectRejectedOnSettingsPage(
{ n8n, api, packageDisk },
v3,
/isn't compatible with your version of n8n/,
);
});
test('rejects updating to a level 3 release and keeps the installed one', async ({
api,
packageDisk,
}) => {
expect((await api.communityPackages.install(versionedName(legacy))).status()).toBe(200);
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');
await expectInstalled(api, packageDisk, legacy);
const state = await packageDisk.stateOf(legacyV3.name);
expect(state.importMarkers).not.toContain(importMarkerName(legacyV3));
});
test('explains the rejected update on the settings page', async ({
n8n,
api,
packageDisk,
}) => {
expect((await api.communityPackages.install(versionedName(legacy))).status()).toBe(200);
await n8n.navigate.toCommunityNodes();
await expect(n8n.communityNodes.getCommunityCard(legacy.name)).toContainText(
`v${legacy.version}`,
);
await n8n.communityNodes.updatePackage(legacy.name);
await expect(
n8n.notifications.getNotificationByTitle('Update not compatible with this n8n version'),
).toBeVisible();
await expect(n8n.communityNodes.getCommunityCard(legacy.name)).toContainText(
`v${legacy.version}`,
);
await expectInstalled(api, packageDisk, legacy);
const state = await packageDisk.stateOf(legacyV3.name);
expect(state.importMarkers).not.toContain(importMarkerName(legacyV3));
});
});
test.describe('on an instance at node API level 3', () => {
test.beforeEach(({ supportedNodesApiVersion }) => {
test.skip(supportedNodesApiVersion < 3, 'this instance does not support level 3');
});
test('installs a level 3 package', async ({ api, packageDisk }) => {
const response = await api.communityPackages.install(versionedName(v3));
expect(response.status()).toBe(200);
await expectInstalled(api, packageDisk, v3);
});
test('updates to a level 3 release', async ({ api, packageDisk }) => {
expect((await api.communityPackages.install(versionedName(legacy))).status()).toBe(200);
const response = await api.communityPackages.update(legacyV3.name, legacyV3.version);
expect(response.status()).toBe(200);
await expectInstalled(api, packageDisk, legacyV3);
});
});
},
);
@@ -0,0 +1,115 @@
import { TEST_CONTAINER_IMAGES } from 'n8n-containers/test-containers';
import type { N8NStack } from 'n8n-containers/stack';
import { mkdtempSync } from 'node:fs';
import { cp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
expect,
importMarkerName,
INSTALLABLE_PACKAGES,
LEGACY_PACKAGE_V3_UPDATE,
nodeType,
test,
} from '../../../fixtures/community-packages';
import { versionedName } from '../../../fixtures/community-packages/assertions';
import type { ApiHelpers } from '../../../services/api-helper';
/**
* A compatible release is installed, its folder on disk is swapped for the
* release declaring node API level 3, and n8n restarts on the same user folder.
*/
const HOME_DIR = mkdtempSync(join(tmpdir(), 'community-packages-startup-'));
const onDisk = LEGACY_PACKAGE_V3_UPDATE;
test.use({
capability: {
services: ['npmRegistry'],
env: {
N8N_UNVERIFIED_PACKAGES_ENABLED: 'true',
HOME: '/home/node',
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'false',
},
userHomeHostDir: HOME_DIR,
user: `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`,
},
});
async function restartWithLevel3OnDisk(api: ApiHelpers, stack: N8NStack) {
await api.enableFeature('communityNodes:customRegistry');
const install = await api.communityPackages.install(versionedName(INSTALLABLE_PACKAGES.legacy));
expect(install.status()).toBe(200);
const packageDir = join(HOME_DIR, '.n8n', 'nodes', 'node_modules', onDisk.name);
await rm(packageDir, { recursive: true, force: true });
await cp(onDisk.directory, packageDir, { recursive: true });
await stack.replaceN8N({ image: TEST_CONTAINER_IMAGES.n8n });
}
async function readMainLogs(stack: N8NStack) {
const [container] = stack.findContainers(/-n8n$/);
const stream = await container.logs();
const chunks: Buffer[] = [];
await new Promise<void>((resolve) => {
stream.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
setTimeout(resolve, 2000);
});
return Buffer.concat(chunks).toString('utf8');
}
test.describe(
'Community node API version guard at startup @mode:sqlite',
{ annotation: [{ type: 'owner', description: 'NODES' }] },
() => {
test.afterAll(async () => {
await rm(HOME_DIR, { recursive: true, force: true });
});
test('skips a package whose on-disk release requires a newer node API', async ({
api,
n8n,
n8nContainer,
packageDisk,
supportedNodesApiVersion,
}) => {
test.skip(supportedNodesApiVersion >= 3, 'this instance supports level 3');
await restartWithLevel3OnDisk(api, n8nContainer);
await expect(api.communityPackages.find(onDisk.name)).resolves.toMatchObject({
failedLoading: true,
});
await expect(api.communityPackages.nodeTypeNames()).resolves.not.toContain(nodeType(onDisk));
await expect(readMainLogs(n8nContainer)).resolves.toContain(
`Skipping package "${onDisk.name}"`,
);
const state = await packageDisk.stateOf(onDisk.name);
expect(state.importMarkers).not.toContain(importMarkerName(onDisk));
await n8n.navigate.toCommunityNodes();
await expect(n8n.communityNodes.getFailedToLoadIcon(onDisk.name)).toBeVisible();
});
test('loads a package whose on-disk release declares the supported node API', async ({
api,
n8n,
n8nContainer,
packageDisk,
supportedNodesApiVersion,
}) => {
test.skip(supportedNodesApiVersion < 3, 'this instance does not support level 3');
await restartWithLevel3OnDisk(api, n8nContainer);
await expect(api.communityPackages.find(onDisk.name)).resolves.toMatchObject({
failedLoading: false,
});
await expect(api.communityPackages.nodeTypeNames()).resolves.toContain(nodeType(onDisk));
const state = await packageDisk.stateOf(onDisk.name);
expect(state.importMarkers).toContain(importMarkerName(onDisk));
await n8n.navigate.toCommunityNodes();
await expect(n8n.communityNodes.getCommunityCard(onDisk.name)).toBeVisible();
await expect(n8n.communityNodes.getFailedToLoadIcon(onDisk.name)).toBeHidden();
});
},
);
+1
View File
@@ -48,6 +48,7 @@ packages:
- packages/modules/**
- packages/extensions/**
- packages/quality/**
- '!packages/quality/testing/playwright/fixtures/community-packages/packages/**'
catalog:
'@ai-sdk/alibaba': ^2.0.32