mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-15 17:14:05 -08:00
1d2666b37c
This PR is an example for how we can 1. improve typing and remove boilerplate code in may of our nodes 2. use nock to write effective unit tests for nodes that make external calls ## Review / Merge checklist - [x] PR title and summary are descriptive - [x] Add tests
438 lines
12 KiB
TypeScript
438 lines
12 KiB
TypeScript
import { readFileSync, readdirSync, mkdtempSync } from 'fs';
|
|
import path from 'path';
|
|
import { tmpdir } from 'os';
|
|
import nock from 'nock';
|
|
import { isEmpty } from 'lodash';
|
|
import { get } from 'lodash';
|
|
import { BinaryDataService, Credentials, constructExecutionMetaData } from 'n8n-core';
|
|
import { Container } from 'typedi';
|
|
import type {
|
|
CredentialLoadingDetails,
|
|
ICredentialDataDecryptedObject,
|
|
ICredentialType,
|
|
ICredentialTypeData,
|
|
ICredentialTypes,
|
|
IDataObject,
|
|
IDeferredPromise,
|
|
IExecuteFunctions,
|
|
IExecuteWorkflowInfo,
|
|
IGetNodeParameterOptions,
|
|
IHttpRequestHelper,
|
|
IHttpRequestOptions,
|
|
INode,
|
|
INodeCredentials,
|
|
INodeCredentialsDetails,
|
|
INodeType,
|
|
INodeTypeData,
|
|
INodeTypes,
|
|
IRun,
|
|
ITaskData,
|
|
IVersionedNodeType,
|
|
IWorkflowBase,
|
|
IWorkflowExecuteAdditionalData,
|
|
NodeLoadingDetails,
|
|
WorkflowTestData,
|
|
} from 'n8n-workflow';
|
|
import { ApplicationError, ICredentialsHelper, NodeHelpers, WorkflowHooks } from 'n8n-workflow';
|
|
import { executeWorkflow } from './ExecuteWorkflow';
|
|
|
|
import { FAKE_CREDENTIALS_DATA } from './FakeCredentialsMap';
|
|
|
|
const baseDir = path.resolve(__dirname, '../..');
|
|
|
|
const getFakeDecryptedCredentials = (
|
|
nodeCredentials: INodeCredentialsDetails,
|
|
type: string,
|
|
fakeCredentialsMap: IDataObject,
|
|
) => {
|
|
if (nodeCredentials && fakeCredentialsMap[JSON.stringify(nodeCredentials)]) {
|
|
return fakeCredentialsMap[JSON.stringify(nodeCredentials)] as ICredentialDataDecryptedObject;
|
|
}
|
|
|
|
if (type && fakeCredentialsMap[type]) {
|
|
return fakeCredentialsMap[type] as ICredentialDataDecryptedObject;
|
|
}
|
|
|
|
return {};
|
|
};
|
|
|
|
export const readJsonFileSync = <T = any>(filePath: string) =>
|
|
JSON.parse(readFileSync(path.join(baseDir, filePath), 'utf-8')) as T;
|
|
|
|
const knownCredentials = readJsonFileSync<Record<string, CredentialLoadingDetails>>(
|
|
'dist/known/credentials.json',
|
|
);
|
|
|
|
const knownNodes = readJsonFileSync<Record<string, NodeLoadingDetails>>('dist/known/nodes.json');
|
|
|
|
class CredentialType implements ICredentialTypes {
|
|
credentialTypes: ICredentialTypeData = {};
|
|
|
|
addCredential(credentialTypeName: string, credentialType: ICredentialType) {
|
|
this.credentialTypes[credentialTypeName] = {
|
|
sourcePath: '',
|
|
type: credentialType,
|
|
};
|
|
}
|
|
|
|
recognizes(credentialType: string): boolean {
|
|
return credentialType in this.credentialTypes;
|
|
}
|
|
|
|
getByName(credentialType: string): ICredentialType {
|
|
return this.credentialTypes[credentialType].type;
|
|
}
|
|
|
|
getSupportedNodes(type: string): string[] {
|
|
return knownCredentials[type]?.supportedNodes ?? [];
|
|
}
|
|
|
|
getParentTypes(typeName: string): string[] {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export class CredentialsHelper extends ICredentialsHelper {
|
|
constructor(private credentialTypes: ICredentialTypes) {
|
|
super('');
|
|
}
|
|
|
|
async authenticate(
|
|
credentials: ICredentialDataDecryptedObject,
|
|
typeName: string,
|
|
requestParams: IHttpRequestOptions,
|
|
): Promise<IHttpRequestOptions> {
|
|
const credentialType = this.credentialTypes.getByName(typeName);
|
|
if (typeof credentialType.authenticate === 'function') {
|
|
return credentialType.authenticate(credentials, requestParams);
|
|
}
|
|
return requestParams;
|
|
}
|
|
|
|
async preAuthentication(
|
|
helpers: IHttpRequestHelper,
|
|
credentials: ICredentialDataDecryptedObject,
|
|
typeName: string,
|
|
node: INode,
|
|
credentialsExpired: boolean,
|
|
): Promise<ICredentialDataDecryptedObject | undefined> {
|
|
return undefined;
|
|
}
|
|
|
|
getParentTypes(name: string): string[] {
|
|
return [];
|
|
}
|
|
|
|
async getDecrypted(
|
|
additionalData: IWorkflowExecuteAdditionalData,
|
|
nodeCredentials: INodeCredentialsDetails,
|
|
type: string,
|
|
): Promise<ICredentialDataDecryptedObject> {
|
|
return getFakeDecryptedCredentials(nodeCredentials, type, FAKE_CREDENTIALS_DATA);
|
|
}
|
|
|
|
async getCredentials(
|
|
nodeCredentials: INodeCredentialsDetails,
|
|
type: string,
|
|
): Promise<Credentials> {
|
|
return new Credentials({ id: null, name: '' }, '', [], '');
|
|
}
|
|
|
|
async updateCredentials(
|
|
nodeCredentials: INodeCredentialsDetails,
|
|
type: string,
|
|
data: ICredentialDataDecryptedObject,
|
|
): Promise<void> {}
|
|
}
|
|
|
|
export function WorkflowExecuteAdditionalData(
|
|
waitPromise: IDeferredPromise<IRun>,
|
|
nodeExecutionOrder: string[],
|
|
workflowTestData?: WorkflowTestData,
|
|
): IWorkflowExecuteAdditionalData {
|
|
const hookFunctions = {
|
|
nodeExecuteAfter: [
|
|
async (nodeName: string, data: ITaskData): Promise<void> => {
|
|
nodeExecutionOrder.push(nodeName);
|
|
},
|
|
],
|
|
workflowExecuteAfter: [
|
|
async (fullRunData: IRun): Promise<void> => {
|
|
waitPromise.resolve(fullRunData);
|
|
},
|
|
],
|
|
};
|
|
|
|
const workflowData: IWorkflowBase = {
|
|
name: '',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
active: true,
|
|
nodes: [],
|
|
connections: {},
|
|
};
|
|
return {
|
|
credentialsHelper: new CredentialsHelper(credentialTypes),
|
|
hooks: new WorkflowHooks(hookFunctions, 'trigger', '1', workflowData),
|
|
executeWorkflow: async (workflowInfo: IExecuteWorkflowInfo): Promise<any> => {},
|
|
sendDataToUI: (message: string) => {},
|
|
restApiUrl: '',
|
|
webhookBaseUrl: 'webhook',
|
|
webhookWaitingBaseUrl: 'webhook-waiting',
|
|
webhookTestBaseUrl: 'webhook-test',
|
|
userId: '123',
|
|
variables: {},
|
|
instanceBaseUrl: '',
|
|
};
|
|
}
|
|
|
|
class NodeTypes implements INodeTypes {
|
|
nodeTypes: INodeTypeData = {};
|
|
|
|
getByName(nodeType: string): INodeType | IVersionedNodeType {
|
|
return this.nodeTypes[nodeType].type;
|
|
}
|
|
|
|
addNode(nodeTypeName: string, nodeType: INodeType | IVersionedNodeType) {
|
|
const loadedNode = {
|
|
[nodeTypeName]: {
|
|
sourcePath: '',
|
|
type: nodeType,
|
|
},
|
|
};
|
|
this.nodeTypes = {
|
|
...this.nodeTypes,
|
|
...loadedNode,
|
|
};
|
|
}
|
|
|
|
getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
|
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
|
|
}
|
|
}
|
|
|
|
export function createTemporaryDir(prefix = 'n8n') {
|
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
|
}
|
|
|
|
export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') {
|
|
const binaryDataService = new BinaryDataService();
|
|
await binaryDataService.init({
|
|
mode: 'default',
|
|
availableModes: [mode],
|
|
localStoragePath: createTemporaryDir(),
|
|
});
|
|
Container.set(BinaryDataService, binaryDataService);
|
|
}
|
|
|
|
const credentialTypes = new CredentialType();
|
|
|
|
export function setup(testData: WorkflowTestData[] | WorkflowTestData) {
|
|
if (!Array.isArray(testData)) {
|
|
testData = [testData];
|
|
}
|
|
|
|
if (testData.some((t) => !!t.nock)) {
|
|
beforeAll(() => {
|
|
nock.disableNetConnect();
|
|
});
|
|
|
|
afterAll(() => {
|
|
nock.restore();
|
|
});
|
|
}
|
|
|
|
const nodeTypes = new NodeTypes();
|
|
|
|
const nodes = [...new Set(testData.flatMap((data) => data.input.workflowData.nodes))];
|
|
const credentialNames = nodes
|
|
.filter((n) => n.credentials)
|
|
.flatMap(({ credentials }) => Object.keys(credentials as INodeCredentials));
|
|
for (const credentialName of credentialNames) {
|
|
const loadInfo = knownCredentials[credentialName];
|
|
if (!loadInfo) {
|
|
throw new ApplicationError(`Unknown credential type: ${credentialName}`, {
|
|
level: 'warning',
|
|
});
|
|
}
|
|
const sourcePath = loadInfo.sourcePath.replace(/^dist\//, './').replace(/\.js$/, '.ts');
|
|
const nodeSourcePath = path.join(baseDir, sourcePath);
|
|
const credential = new (require(nodeSourcePath)[loadInfo.className])() as ICredentialType;
|
|
credentialTypes.addCredential(credentialName, credential);
|
|
}
|
|
|
|
const nodeNames = nodes.map((n) => n.type);
|
|
for (const nodeName of nodeNames) {
|
|
if (!nodeName.startsWith('n8n-nodes-base.')) {
|
|
throw new ApplicationError(`Unknown node type: ${nodeName}`, { level: 'warning' });
|
|
}
|
|
const loadInfo = knownNodes[nodeName.replace('n8n-nodes-base.', '')];
|
|
if (!loadInfo) {
|
|
throw new ApplicationError(`Unknown node type: ${nodeName}`, { level: 'warning' });
|
|
}
|
|
const sourcePath = loadInfo.sourcePath.replace(/^dist\//, './').replace(/\.js$/, '.ts');
|
|
const nodeSourcePath = path.join(baseDir, sourcePath);
|
|
const node = new (require(nodeSourcePath)[loadInfo.className])() as INodeType;
|
|
nodeTypes.addNode(nodeName, node);
|
|
}
|
|
|
|
return nodeTypes;
|
|
}
|
|
|
|
export function getResultNodeData(result: IRun, testData: WorkflowTestData) {
|
|
return Object.keys(testData.output.nodeData).map((nodeName) => {
|
|
const error = result.data.resultData.error;
|
|
// If there was an error running the workflow throw it for easier debugging
|
|
// and to surface all issues
|
|
if (error?.cause) throw error.cause;
|
|
if (error) throw error;
|
|
|
|
if (result.data.resultData.runData[nodeName] === undefined) {
|
|
// log errors from other nodes
|
|
Object.keys(result.data.resultData.runData).forEach((key) => {
|
|
const error = result.data.resultData.runData[key][0]?.error;
|
|
if (error) {
|
|
console.log(`Node ${key}\n`, error);
|
|
}
|
|
});
|
|
|
|
throw new ApplicationError(`Data for node "${nodeName}" is missing!`, { level: 'warning' });
|
|
}
|
|
const resultData = result.data.resultData.runData[nodeName].map((nodeData) => {
|
|
if (nodeData.data === undefined) {
|
|
return null;
|
|
}
|
|
return nodeData.data.main[0]!.map((entry) => {
|
|
if (entry.binary && isEmpty(entry.binary)) delete entry.binary;
|
|
delete entry.pairedItem;
|
|
return entry;
|
|
});
|
|
});
|
|
return {
|
|
nodeName,
|
|
resultData,
|
|
};
|
|
});
|
|
}
|
|
|
|
export const equalityTest = async (testData: WorkflowTestData, types: INodeTypes) => {
|
|
// execute workflow
|
|
const { result } = await executeWorkflow(testData, types);
|
|
|
|
// check if result node data matches expected test data
|
|
const resultNodeData = getResultNodeData(result, testData);
|
|
resultNodeData.forEach(({ nodeName, resultData }) => {
|
|
const msg = `Equality failed for "${testData.description}" at node "${nodeName}"`;
|
|
resultData.forEach((item) => {
|
|
item?.forEach(({ binary }) => {
|
|
if (binary) {
|
|
// @ts-ignore
|
|
delete binary.data.data;
|
|
delete binary.data.directory;
|
|
}
|
|
});
|
|
});
|
|
return expect(resultData, msg).toEqual(testData.output.nodeData[nodeName]);
|
|
});
|
|
|
|
expect(result.finished).toEqual(true);
|
|
};
|
|
|
|
const preparePinData = (pinData: IDataObject) => {
|
|
const returnData = Object.keys(pinData).reduce(
|
|
(acc, key) => {
|
|
const data = pinData[key] as IDataObject[];
|
|
acc[key] = [data];
|
|
return acc;
|
|
},
|
|
{} as {
|
|
[key: string]: IDataObject[][];
|
|
},
|
|
);
|
|
return returnData;
|
|
};
|
|
export const workflowToTests = (workflowFiles: string[]) => {
|
|
const testCases: WorkflowTestData[] = [];
|
|
for (const filePath of workflowFiles) {
|
|
const description = filePath.replace('.json', '');
|
|
const workflowData = readJsonFileSync<IWorkflowBase & Pick<WorkflowTestData, 'trigger'>>(
|
|
filePath,
|
|
);
|
|
const testDir = path.join(baseDir, path.dirname(filePath));
|
|
workflowData.nodes.forEach((node) => {
|
|
if (node.parameters) {
|
|
node.parameters = JSON.parse(
|
|
JSON.stringify(node.parameters).replace(/"C:\\\\Test\\\\(.*)"/, `"${testDir}/$1"`),
|
|
);
|
|
}
|
|
});
|
|
if (workflowData.pinData === undefined) {
|
|
throw new ApplicationError('Workflow data does not contain pinData', { level: 'warning' });
|
|
}
|
|
|
|
const nodeData = preparePinData(workflowData.pinData);
|
|
delete workflowData.pinData;
|
|
|
|
const { trigger } = workflowData;
|
|
delete workflowData.trigger;
|
|
|
|
const input = { workflowData };
|
|
const output = { nodeData };
|
|
|
|
testCases.push({ description, input, output, trigger });
|
|
}
|
|
return testCases;
|
|
};
|
|
|
|
export const testWorkflows = (workflows: string[]) => {
|
|
const tests = workflowToTests(workflows);
|
|
|
|
const nodeTypes = setup(tests);
|
|
|
|
for (const testData of tests) {
|
|
test(testData.description, async () => equalityTest(testData, nodeTypes));
|
|
}
|
|
};
|
|
|
|
export const getWorkflowFilenames = (dirname: string) => {
|
|
const workflows: string[] = [];
|
|
|
|
const filenames = readdirSync(dirname);
|
|
const testFolder = dirname.split(`${path.sep}nodes-base${path.sep}`)[1];
|
|
filenames.forEach((file) => {
|
|
if (file.endsWith('.json')) {
|
|
workflows.push(path.join(testFolder, file));
|
|
}
|
|
});
|
|
|
|
return workflows;
|
|
};
|
|
|
|
export const createMockExecuteFunction = (
|
|
nodeParameters: IDataObject,
|
|
nodeMock: INode,
|
|
continueBool = false,
|
|
) => {
|
|
const fakeExecuteFunction = {
|
|
getNodeParameter(
|
|
parameterName: string,
|
|
_itemIndex: number,
|
|
fallbackValue?: IDataObject | undefined,
|
|
options?: IGetNodeParameterOptions | undefined,
|
|
) {
|
|
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
|
|
return get(nodeParameters, parameter, fallbackValue);
|
|
},
|
|
getNode() {
|
|
return nodeMock;
|
|
},
|
|
continueOnFail() {
|
|
return continueBool;
|
|
},
|
|
helpers: {
|
|
constructExecutionMetaData,
|
|
},
|
|
} as unknown as IExecuteFunctions;
|
|
return fakeExecuteFunction;
|
|
};
|