n8n-nodes-teamorouter/test/validate-nodes.mjs

118 lines
5.6 KiB
JavaScript

// 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`);
}
}
}
}
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(', ')}]`,
);