// Loads the compiled node package the way n8n does and fails loudly on anything n8n would reject. import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const packageJson = require(join(root, 'package.json')); const loaded = { nodes: packageJson.n8n.nodes.map((file) => { const module = require(join(root, file)); const exported = Object.values(module).find((value) => typeof value === 'function'); assert.ok(exported, `${file} exports no class`); return { file, instance: new exported() }; }), credentials: packageJson.n8n.credentials.map((file) => { const module = require(join(root, file)); const exported = Object.values(module).find((value) => typeof value === 'function'); assert.ok(exported, `${file} exports no class`); return { file, instance: new exported() }; }), }; const credentialNames = new Set(loaded.credentials.map(({ instance }) => instance.name)); for (const { file, instance } of loaded.credentials) { assert.equal(typeof instance.name, 'string', `${file}: credential has no name`); assert.equal(typeof instance.displayName, 'string', `${file}: credential has no displayName`); assert.ok(Array.isArray(instance.properties) && instance.properties.length > 0, `${file}: no properties`); assert.ok(instance.authenticate, `${file}: no authenticate block`); assert.ok(instance.test?.request, `${file}: no credential test request`); const keyProperty = instance.properties.find((property) => property.name === 'apiKey'); assert.ok(keyProperty?.typeOptions?.password, `${file}: apiKey must be a password field`); } for (const { file, instance } of loaded.nodes) { const description = instance.description; const label = `${file}: `; assert.ok(description, `${label}no description`); assert.equal(typeof description.displayName, 'string', `${label}displayName missing`); assert.match(description.name, /^[a-zA-Z][a-zA-Z0-9]*$/, `${label}name must be camelCase`); assert.ok(description.version !== undefined, `${label}version missing`); assert.ok(description.defaults?.name, `${label}defaults.name missing`); assert.ok(Array.isArray(description.properties), `${label}properties missing`); assert.ok(description.inputs !== undefined, `${label}inputs missing`); assert.ok(description.outputs !== undefined, `${label}outputs missing`); assert.ok( existsSync(join(dirname(join(root, file)), description.icon.replace('file:', ''))), `${label}icon ${description.icon} not found next to the compiled node`, ); const names = description.properties.map((property) => property.name); // The same name may be reused for different resources (n8n's own nodes do this with "operation"), // but never for the same visible state. const seen = new Map(); for (const property of description.properties) { const show = JSON.stringify(property.displayOptions?.show ?? {}); const key = `${property.name}|${show}`; assert.ok(!seen.has(key), `${label}duplicate parameter "${property.name}" with identical display options`); seen.set(key, true); } assert.equal(names.length > 0, true, `${label}no parameters`); const loadOptionsMethods = description.properties .filter((property) => property.typeOptions?.loadOptionsMethod) .map((property) => property.typeOptions.loadOptionsMethod); for (const method of loadOptionsMethods) { assert.equal( typeof instance.methods?.loadOptions?.[method], 'function', `${label}parameter uses loadOptionsMethod "${method}" but the node does not implement it`, ); } assert.ok(loadOptionsMethods.length > 0, `${label}no dynamic model list`); for (const credential of description.credentials ?? []) { assert.ok(credentialNames.has(credential.name), `${label}references unknown credential ${credential.name}`); } for (const property of description.properties) { if (property.typeOptions?.loadOptionsMethod !== undefined) continue; // dynamic list, filled at runtime if (property.type === 'options') { assert.ok(Array.isArray(property.options) && property.options.length > 0, `${label}"${property.name}" has no options`); for (const option of property.options) { assert.equal(typeof option.value, 'string', `${label}option of "${property.name}" without a value`); assert.ok(option.value !== '', `${label}option of "${property.name}" with an empty value`); } assert.ok( property.options.some((option) => option.value === property.default), `${label}default "${property.default}" of "${property.name}" is not one of its options`, ); } if (property.type === 'collection') { for (const option of property.options ?? []) { assert.equal(typeof option.name, 'string', `${label}collection "${property.name}" has an option without a name`); assert.equal(typeof option.type, 'string', `${label}collection "${property.name}" has an option without a type`); } } } } // n8n builds a Workflow object — resolving every parameter against its displayOptions — before an // execution exists, and it builds it again from the parameters it wrote back. If that resolution // loops the webhook request dies with "There was a problem executing the workflow" and no run at all. // Building with every collection present but empty is the shape that used to break. const { Workflow } = require('n8n-workflow'); for (const { instance } of loaded.nodes) { const containers = instance.description.properties .filter((property) => property.type === 'collection' || property.type === 'fixedCollection') .map((property) => property.name); const resources = instance.description.properties.find((property) => property.name === 'resource') ?.options ?? [{ value: undefined }]; const build = (parameters) => new Workflow({ id: 'validation', nodes: [ { id: 'validation-node', name: instance.description.displayName, type: instance.description.name, typeVersion: instance.description.version, position: [0, 0], parameters: JSON.parse(JSON.stringify(parameters)), }, ], connections: {}, active: false, nodeTypes: { getByNameAndVersion: () => instance, getByName: () => instance }, }); for (const { value: resource } of resources) { const parameters = { resource, ...Object.fromEntries(containers.map((name) => [name, {}])) }; // The parameters of the first pass are the ones n8n stores and reads back. const firstPass = build(parameters); build(firstPass.nodes[instance.description.displayName].parameters); } } const chatModel = loaded.nodes.find(({ instance }) => instance.description.name === 'lmChatTeamRouter').instance; assert.deepEqual(chatModel.description.inputs, [], 'chat model sub-node must not have a main input'); assert.ok( chatModel.description.outputs.includes('ai_languageModel'), 'chat model sub-node must output the ai_languageModel connection type', ); assert.equal(typeof chatModel.supplyData, 'function', 'chat model sub-node must implement supplyData'); console.error( `OK validation: ${loaded.nodes.length} nodes, ${loaded.credentials.length} credential(s) [${[...credentialNames].join(', ')}]`, );