From 7576de5f26ccfd20c1aceee0e6f5657833e267d9 Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Fri, 2 Oct 2026 09:23:26 +0000 Subject: [PATCH] test: Add E2E coverage for the community node API version guard (no-changelog) (#39805) --- .../testing/playwright/eslint.config.mjs | 1 + .../fixtures/community-packages/.gitignore | 1 + .../fixtures/community-packages/assertions.ts | 40 +++++ .../community-packages/fixture-packages.ts | 59 +++++++ .../fixtures/community-packages/index.ts | 81 +++++++++ .../dist/CompatProbe.node.js | 32 ++++ .../package.json | 11 ++ .../dist/CompatProbe.node.js | 32 ++++ .../package.json | 12 ++ .../dist/CompatProbe.node.js | 32 ++++ .../package.json | 12 ++ .../dist/CompatProbe.node.js | 32 ++++ .../n8n-nodes-compat-v1-1.0.0/package.json | 12 ++ .../dist/CompatProbe.node.js | 32 ++++ .../n8n-nodes-compat-v3-1.0.0/package.json | 12 ++ .../testing/playwright/oxlint.config.mts | 1 + .../quality/testing/playwright/package.json | 1 + .../playwright/pages/CommunityNodesPage.ts | 34 +++- .../testing/playwright/services/api-helper.ts | 3 + .../services/community-packages-api-helper.ts | 93 ++++++++++ .../node-api-version-guard.spec.ts | 164 ++++++++++++++++++ .../community-packages/startup-guard.spec.ts | 115 ++++++++++++ pnpm-workspace.yaml | 1 + 23 files changed, 806 insertions(+), 7 deletions(-) create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/.gitignore create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/assertions.ts create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/fixture-packages.ts create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/index.ts create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/dist/CompatProbe.node.js create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/package.json create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/dist/CompatProbe.node.js create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/package.json create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/dist/CompatProbe.node.js create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/package.json create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/dist/CompatProbe.node.js create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/package.json create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/dist/CompatProbe.node.js create mode 100644 packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/package.json create mode 100644 packages/quality/testing/playwright/services/community-packages-api-helper.ts create mode 100644 packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts create mode 100644 packages/quality/testing/playwright/tests/infrastructure/community-packages/startup-guard.spec.ts diff --git a/packages/quality/testing/playwright/eslint.config.mjs b/packages/quality/testing/playwright/eslint.config.mjs index 3bd38b1049f..9aea8870e27 100644 --- a/packages/quality/testing/playwright/eslint.config.mjs +++ b/packages/quality/testing/playwright/eslint.config.mjs @@ -16,6 +16,7 @@ export default [ 'coverage/**/*', 'scripts/**/*', 'janitor.config.mjs', + 'fixtures/community-packages/packages/**/*.js', ], }, { diff --git a/packages/quality/testing/playwright/fixtures/community-packages/.gitignore b/packages/quality/testing/playwright/fixtures/community-packages/.gitignore new file mode 100644 index 00000000000..dbcc9e09397 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/.gitignore @@ -0,0 +1 @@ +!packages/*/dist/ diff --git a/packages/quality/testing/playwright/fixtures/community-packages/assertions.ts b/packages/quality/testing/playwright/fixtures/community-packages/assertions.ts new file mode 100644 index 00000000000..20d52c09ac4 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/assertions.ts @@ -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); +} diff --git a/packages/quality/testing/playwright/fixtures/community-packages/fixture-packages.ts b/packages/quality/testing/playwright/fixtures/community-packages/fixture-packages.ts new file mode 100644 index 00000000000..4d3173aeb91 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/fixture-packages.ts @@ -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; + +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`; diff --git a/packages/quality/testing/playwright/fixtures/community-packages/index.ts b/packages/quality/testing/playwright/fixtures/community-packages/index.ts new file mode 100644 index 00000000000..fe08a937e7c --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/index.ts @@ -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//package.json`, `undefined` when not on disk. */ + installedVersion: string | undefined; + /** Import markers (`@`) the package's node module wrote when required. */ + importMarkers: string[]; +} + +export interface PackageDisk { + stateOf(packageName: string): Promise; +} + +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, + Pick +>({ + 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}@`), + ), + }; + }, + }); + }, +}); diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/dist/CompatProbe.node.js b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/dist/CompatProbe.node.js new file mode 100644 index 00000000000..b12a0b5dcde --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/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 }; diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/package.json b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/package.json new file mode 100644 index 00000000000..f0031844d79 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-1.0.0/package.json @@ -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"] + } +} diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/dist/CompatProbe.node.js b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/dist/CompatProbe.node.js new file mode 100644 index 00000000000..b12a0b5dcde --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/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 }; diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/package.json b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/package.json new file mode 100644 index 00000000000..de029355e93 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-legacy-2.0.0/package.json @@ -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"] + } +} diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/dist/CompatProbe.node.js b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/dist/CompatProbe.node.js new file mode 100644 index 00000000000..b12a0b5dcde --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/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 }; diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/package.json b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/package.json new file mode 100644 index 00000000000..436c7b82d85 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-malformed-1.0.0/package.json @@ -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"] + } +} diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/dist/CompatProbe.node.js b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/dist/CompatProbe.node.js new file mode 100644 index 00000000000..b12a0b5dcde --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/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 }; diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/package.json b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/package.json new file mode 100644 index 00000000000..48673ca8b44 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v1-1.0.0/package.json @@ -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"] + } +} diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/dist/CompatProbe.node.js b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/dist/CompatProbe.node.js new file mode 100644 index 00000000000..b12a0b5dcde --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/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 }; diff --git a/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/package.json b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/package.json new file mode 100644 index 00000000000..6062b037ab6 --- /dev/null +++ b/packages/quality/testing/playwright/fixtures/community-packages/packages/n8n-nodes-compat-v3-1.0.0/package.json @@ -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"] + } +} diff --git a/packages/quality/testing/playwright/oxlint.config.mts b/packages/quality/testing/playwright/oxlint.config.mts index f532dc2fc87..9c1f4bb20f7 100644 --- a/packages/quality/testing/playwright/oxlint.config.mts +++ b/packages/quality/testing/playwright/oxlint.config.mts @@ -12,6 +12,7 @@ export default defineConfig({ 'coverage/**/*', 'scripts/**/*', 'janitor.config.mjs', + 'fixtures/community-packages/packages/**/*.js', ], rules: { 'playwright/missing-playwright-await': 'error', diff --git a/packages/quality/testing/playwright/package.json b/packages/quality/testing/playwright/package.json index 9183db073be..1d1637926ab 100644 --- a/packages/quality/testing/playwright/package.json +++ b/packages/quality/testing/playwright/package.json @@ -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:*'", diff --git a/packages/quality/testing/playwright/pages/CommunityNodesPage.ts b/packages/quality/testing/playwright/pages/CommunityNodesPage.ts index 05be3916ff0..0d07868af84 100644 --- a/packages/quality/testing/playwright/pages/CommunityNodesPage.ts +++ b/packages/quality/testing/playwright/pages/CommunityNodesPage.ts @@ -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 { - await this.getUpdateButton().click(); + async clickUpdateButton(packageName?: string): Promise { + await this.getUpdateButton(packageName).click(); } async clickConfirmUpdate(): Promise { @@ -105,18 +120,23 @@ export class CommunityNodesPage extends BasePage { } // Helper methods for common workflows - async installPackage(packageName: string): Promise { + /** Fills and submits the install dialog without waiting for the result. */ + async submitInstall(packageName: string): Promise { await this.clickInstallButton(); await this.fillPackageName(packageName); await this.clickUserAgreementCheckbox(); await this.clickInstallPackageButton(); + } + + async installPackage(packageName: string): Promise { + await this.submitInstall(packageName); // Wait for install modal to close await this.getInstallModal().waitFor({ state: 'hidden' }); } - async updatePackage(): Promise { - await this.clickUpdateButton(); + async updatePackage(packageName?: string): Promise { + await this.clickUpdateButton(packageName); await this.clickConfirmUpdate(); } diff --git a/packages/quality/testing/playwright/services/api-helper.ts b/packages/quality/testing/playwright/services/api-helper.ts index edcf5ce51f8..31b459c8867 100644 --- a/packages/quality/testing/playwright/services/api-helper.ts +++ b/packages/quality/testing/playwright/services/api-helper.ts @@ -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); } diff --git a/packages/quality/testing/playwright/services/community-packages-api-helper.ts b/packages/quality/testing/playwright/services/community-packages-api-helper.ts new file mode 100644 index 00000000000..739aa620929 --- /dev/null +++ b/packages/quality/testing/playwright/services/community-packages-api-helper.ts @@ -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 { + 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 { + 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 { + return await this.api.request.post('/rest/community-packages', { + data: { name: spec }, + timeout: INSTALL_TIMEOUT_MS, + }); + } + + async update(packageName: string, version?: string): Promise { + return await this.api.request.patch('/rest/community-packages', { + data: { name: packageName, version }, + timeout: INSTALL_TIMEOUT_MS, + }); + } + + async uninstall(packageName: string): Promise { + 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 { + for (const pkg of await this.list()) { + await this.uninstall(pkg.packageName); + } + } + + /** Names of every node type the instance currently serves. */ + async nodeTypeNames(): Promise { + 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 { + return (await response.json()) as NodesApiVersionRejection; + } +} diff --git a/packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts b/packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts new file mode 100644 index 00000000000..5f01e64fac0 --- /dev/null +++ b/packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts @@ -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); + }); + }); + }, +); diff --git a/packages/quality/testing/playwright/tests/infrastructure/community-packages/startup-guard.spec.ts b/packages/quality/testing/playwright/tests/infrastructure/community-packages/startup-guard.spec.ts new file mode 100644 index 00000000000..8100096bb5b --- /dev/null +++ b/packages/quality/testing/playwright/tests/infrastructure/community-packages/startup-guard.spec.ts @@ -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((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(); + }); + }, +); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 00197f7d4f8..3598cacf88d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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