mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
test: Add E2E coverage for the community node API version guard (no-changelog) (#39805)
This commit is contained in:
@@ -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}@`),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
+32
@@ -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 };
|
||||
+11
@@ -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"]
|
||||
}
|
||||
}
|
||||
+32
@@ -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 };
|
||||
+12
@@ -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"]
|
||||
}
|
||||
}
|
||||
+32
@@ -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 };
|
||||
+12
@@ -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"]
|
||||
}
|
||||
}
|
||||
+32
@@ -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 };
|
||||
+12
@@ -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"]
|
||||
}
|
||||
}
|
||||
+32
@@ -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 };
|
||||
+12
@@ -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;
|
||||
}
|
||||
}
|
||||
+164
@@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
+115
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user