@5minds/node-red-contrib-processcube-tools 1.2.0-develop-d19f89-mg68thdf → 1.2.0-develop-59ef22-mg9d9ja5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.json +5 -0
- package/package.json +26 -10
- package/src/custom-node-template/custom-node-template.html.template +45 -0
- package/src/custom-node-template/custom-node-template.ts.template +69 -0
- package/src/email-receiver/email-receiver.ts +439 -0
- package/src/email-sender/email-sender.ts +210 -0
- package/{processcube-html-to-text/processcube-html-to-text.html → src/html-to-text/html-to-text.html} +3 -3
- package/src/html-to-text/html-to-text.ts +53 -0
- package/src/index.ts +12 -0
- package/src/interfaces/EmailReceiverMessage.ts +22 -0
- package/src/interfaces/EmailSenderNodeProperties.ts +37 -0
- package/src/interfaces/FetchState.ts +9 -0
- package/src/interfaces/ImapConnectionConfig.ts +14 -0
- package/src/test/framework/advanced-test-patterns.ts +224 -0
- package/src/test/framework/generic-node-test-suite.ts +58 -0
- package/src/test/framework/index.ts +17 -0
- package/src/test/framework/integration-assertions.ts +67 -0
- package/src/test/framework/integration-scenario-builder.ts +77 -0
- package/src/test/framework/integration-test-runner.ts +101 -0
- package/src/test/framework/node-assertions.ts +63 -0
- package/src/test/framework/node-test-runner.ts +260 -0
- package/src/test/framework/test-scenario-builder.ts +74 -0
- package/src/test/framework/types.ts +61 -0
- package/src/test/helpers/email-receiver-test-configs.ts +67 -0
- package/src/test/helpers/email-receiver-test-flows.ts +16 -0
- package/src/test/helpers/email-sender-test-configs.ts +123 -0
- package/src/test/helpers/email-sender-test-flows.ts +16 -0
- package/src/test/integration/email-receiver.integration.test.ts +41 -0
- package/src/test/integration/email-sender.integration.test.ts +129 -0
- package/src/test/interfaces/email-data.ts +10 -0
- package/src/test/interfaces/email-receiver-config.ts +12 -0
- package/src/test/interfaces/email-sender-config.ts +26 -0
- package/src/test/interfaces/imap-config.ts +9 -0
- package/src/test/interfaces/imap-mailbox.ts +5 -0
- package/src/test/interfaces/mail-options.ts +20 -0
- package/src/test/interfaces/parsed-email.ts +11 -0
- package/src/test/interfaces/send-mail-result.ts +7 -0
- package/src/test/mocks/imap-mock.ts +147 -0
- package/src/test/mocks/mailparser-mock.ts +82 -0
- package/src/test/mocks/nodemailer-mock.ts +118 -0
- package/src/test/unit/email-receiver.unit.test.ts +471 -0
- package/src/test/unit/email-sender.unit.test.ts +550 -0
- package/tsconfig.json +23 -0
- package/email-receiver/email-receiver.js +0 -304
- package/email-sender/email-sender.js +0 -178
- package/examples/.gitkeep +0 -0
- package/processcube-html-to-text/processcube-html-to-text.js +0 -22
- package/test/helpers/email-receiver.mocks.js +0 -447
- package/test/helpers/email-sender.mocks.js +0 -368
- package/test/integration/email-receiver.integration.test.js +0 -515
- package/test/integration/email-sender.integration.test.js +0 -239
- package/test/unit/email-receiver.unit.test.js +0 -304
- package/test/unit/email-sender.unit.test.js +0 -570
- /package/{email-receiver → src/email-receiver}/email-receiver.html +0 -0
- /package/{email-sender → src/email-sender}/email-sender.html +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import type { IntegrationTestContext } from './integration-test-runner';
|
|
3
|
+
|
|
4
|
+
export class IntegrationAssertions {
|
|
5
|
+
static expectNodeExists(context: IntegrationTestContext, nodeId: string): void {
|
|
6
|
+
expect(context.nodes[nodeId], `Node ${nodeId} should exist`).to.exist;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static expectNodeProperty(context: IntegrationTestContext, nodeId: string, property: string, value: any): void {
|
|
10
|
+
this.expectNodeExists(context, nodeId);
|
|
11
|
+
expect(context.nodes[nodeId], `Node ${nodeId} should have property ${property}`).to.have.property(
|
|
12
|
+
property,
|
|
13
|
+
value,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static expectMessageReceived(context: IntegrationTestContext, nodeId: string): void {
|
|
18
|
+
const messages = context.messages.filter((m) => m.nodeId === nodeId);
|
|
19
|
+
expect(messages, `Node ${nodeId} should have received at least one message`).to.have.length.greaterThan(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static expectMessageCount(context: IntegrationTestContext, nodeId: string, count: number): void {
|
|
23
|
+
const messages = context.messages.filter((m) => m.nodeId === nodeId);
|
|
24
|
+
expect(messages, `Node ${nodeId} should have received ${count} messages`).to.have.length(count);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static expectMessageContent(context: IntegrationTestContext, nodeId: string, expectedContent: any): void {
|
|
28
|
+
const messages = context.messages.filter((m) => m.nodeId === nodeId);
|
|
29
|
+
expect(messages, `Node ${nodeId} should have received messages`).to.have.length.greaterThan(0);
|
|
30
|
+
|
|
31
|
+
const lastMessage = messages[messages.length - 1].message;
|
|
32
|
+
if (typeof expectedContent === 'object') {
|
|
33
|
+
Object.keys(expectedContent).forEach((key) => {
|
|
34
|
+
expect(lastMessage, `Message should have property ${key}`).to.have.property(key);
|
|
35
|
+
if (expectedContent[key] !== undefined) {
|
|
36
|
+
expect(lastMessage[key], `Message property ${key} should match`).to.deep.equal(
|
|
37
|
+
expectedContent[key],
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
expect(lastMessage.payload).to.equal(expectedContent);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static expectNoMessages(context: IntegrationTestContext, nodeId: string): void {
|
|
47
|
+
const messages = context.messages.filter((m) => m.nodeId === nodeId);
|
|
48
|
+
expect(messages, `Node ${nodeId} should not have received any messages`).to.have.length(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static expectAllNodesExist(context: IntegrationTestContext, nodeIds: string[]): void {
|
|
52
|
+
nodeIds.forEach((nodeId) => {
|
|
53
|
+
this.expectNodeExists(context, nodeId);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static expectMessageOrder(context: IntegrationTestContext, nodeIds: string[]): void {
|
|
58
|
+
const sortedMessages = [...context.messages].sort((a, b) => a.timestamp - b.timestamp);
|
|
59
|
+
|
|
60
|
+
nodeIds.forEach((expectedNodeId, index) => {
|
|
61
|
+
expect(sortedMessages[index], `Message ${index} should be from node ${expectedNodeId}`).to.have.property(
|
|
62
|
+
'nodeId',
|
|
63
|
+
expectedNodeId,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import type { IntegrationTestScenario } from './integration-test-runner';
|
|
3
|
+
import type { Node } from 'node-red';
|
|
4
|
+
|
|
5
|
+
export class IntegrationScenarioBuilder {
|
|
6
|
+
private scenarios: IntegrationTestScenario[] = [];
|
|
7
|
+
|
|
8
|
+
addScenario(scenario: IntegrationTestScenario): this {
|
|
9
|
+
this.scenarios.push(scenario);
|
|
10
|
+
return this;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
addLoadingScenario(name: string, flow: any[], nodeId: string): this {
|
|
14
|
+
return this.addScenario({
|
|
15
|
+
name,
|
|
16
|
+
flow,
|
|
17
|
+
nodeId,
|
|
18
|
+
timeout: 2000,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
addMessageFlowScenario(
|
|
23
|
+
name: string,
|
|
24
|
+
flow: any[],
|
|
25
|
+
sourceNodeId: string,
|
|
26
|
+
input: any,
|
|
27
|
+
expectedMessages: Array<{ nodeId: string; expectedMsg: any }>,
|
|
28
|
+
): this {
|
|
29
|
+
return this.addScenario({
|
|
30
|
+
name,
|
|
31
|
+
flow,
|
|
32
|
+
nodeId: sourceNodeId,
|
|
33
|
+
input,
|
|
34
|
+
expectedMessages,
|
|
35
|
+
timeout: 3000,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
addConnectionScenario(name: string, flow: any[], nodeIds: string[]): this {
|
|
40
|
+
return this.addScenario({
|
|
41
|
+
name,
|
|
42
|
+
flow,
|
|
43
|
+
nodeId: nodeIds[0], // Primary node
|
|
44
|
+
setup: (nodes) => {
|
|
45
|
+
// Verify all nodes are connected
|
|
46
|
+
nodeIds.forEach((nodeId) => {
|
|
47
|
+
expect(nodes[nodeId], `Node ${nodeId} should exist in connection test`).to.exist;
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
addLifecycleScenario(
|
|
54
|
+
name: string,
|
|
55
|
+
flow: any[],
|
|
56
|
+
nodeId: string,
|
|
57
|
+
operations: Array<(nodes: Record<string, Node>) => void>,
|
|
58
|
+
): this {
|
|
59
|
+
return this.addScenario({
|
|
60
|
+
name,
|
|
61
|
+
flow,
|
|
62
|
+
nodeId,
|
|
63
|
+
setup: (nodes) => {
|
|
64
|
+
operations.forEach((operation) => operation(nodes));
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getScenarios(): IntegrationTestScenario[] {
|
|
70
|
+
return [...this.scenarios];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
clear(): this {
|
|
74
|
+
this.scenarios = [];
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Node, NodeMessageInFlow } from 'node-red';
|
|
2
|
+
import { NodeTestRunner } from './node-test-runner';
|
|
3
|
+
import type { TestScenario } from './types';
|
|
4
|
+
|
|
5
|
+
export interface IntegrationTestScenario {
|
|
6
|
+
name: string;
|
|
7
|
+
flow: any[];
|
|
8
|
+
nodeId: string;
|
|
9
|
+
input?: any;
|
|
10
|
+
expectedMessages?: Array<{
|
|
11
|
+
nodeId: string;
|
|
12
|
+
expectedMsg: any;
|
|
13
|
+
timeout?: number;
|
|
14
|
+
}>;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
setup?: (nodes: Record<string, Node>) => void;
|
|
17
|
+
cleanup?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IntegrationTestContext {
|
|
21
|
+
nodes: Record<string, Node>;
|
|
22
|
+
messages: Array<{
|
|
23
|
+
nodeId: string;
|
|
24
|
+
message: NodeMessageInFlow;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
}>;
|
|
27
|
+
errors: any[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class IntegrationTestRunner {
|
|
31
|
+
static async runIntegrationScenario(
|
|
32
|
+
nodeConstructor: Function,
|
|
33
|
+
scenario: IntegrationTestScenario,
|
|
34
|
+
): Promise<IntegrationTestContext> {
|
|
35
|
+
console.log(`[SCENARIO START] ${scenario.name}`);
|
|
36
|
+
|
|
37
|
+
const context: IntegrationTestContext = {
|
|
38
|
+
nodes: {},
|
|
39
|
+
messages: [],
|
|
40
|
+
errors: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const mainNodeConfig = scenario.flow.find((n) => n.id === scenario.nodeId);
|
|
44
|
+
|
|
45
|
+
if (!mainNodeConfig) {
|
|
46
|
+
throw new Error(`Node with id ${scenario.nodeId} not found in flow`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const testScenario: TestScenario = {
|
|
50
|
+
name: scenario.name,
|
|
51
|
+
config: mainNodeConfig,
|
|
52
|
+
input: scenario.input,
|
|
53
|
+
timeout: scenario.timeout || 5000,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (scenario.expectedMessages && scenario.expectedMessages.length > 0) {
|
|
57
|
+
testScenario.expectedOutput = scenario.expectedMessages[0].expectedMsg;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const testContext = await NodeTestRunner.runScenario(nodeConstructor, testScenario, {
|
|
61
|
+
sendHandler: function (msg: any) {
|
|
62
|
+
// When send is called with array (multi-output), handle each output
|
|
63
|
+
if (Array.isArray(msg)) {
|
|
64
|
+
msg.forEach((m, index) => {
|
|
65
|
+
if (scenario.expectedMessages && scenario.expectedMessages[index]) {
|
|
66
|
+
context.messages.push({
|
|
67
|
+
nodeId: scenario.expectedMessages[index].nodeId,
|
|
68
|
+
message: m,
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
// Single message - use first expected message nodeId if available
|
|
75
|
+
const nodeId =
|
|
76
|
+
scenario.expectedMessages && scenario.expectedMessages[0]
|
|
77
|
+
? scenario.expectedMessages[0].nodeId
|
|
78
|
+
: scenario.nodeId;
|
|
79
|
+
|
|
80
|
+
context.messages.push({
|
|
81
|
+
nodeId: nodeId,
|
|
82
|
+
message: msg,
|
|
83
|
+
timestamp: Date.now(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
errorHandler: function (error: any) {
|
|
88
|
+
context.errors.push(error);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
context.nodes[scenario.nodeId] = testContext.nodeInstance;
|
|
93
|
+
|
|
94
|
+
if (scenario.setup) {
|
|
95
|
+
scenario.setup(context.nodes);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`[SCENARIO COMPLETE] ${scenario.name}`);
|
|
99
|
+
return context;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import type { TestContext } from './types';
|
|
3
|
+
|
|
4
|
+
export class NodeAssertions {
|
|
5
|
+
static expectMessage(context: TestContext, expectedMsg: any): void {
|
|
6
|
+
expect(context.messages, 'No messages were sent').to.have.length.greaterThan(0);
|
|
7
|
+
const lastMessage = context.messages[context.messages.length - 1];
|
|
8
|
+
|
|
9
|
+
if (typeof expectedMsg === 'object' && expectedMsg !== null) {
|
|
10
|
+
Object.keys(expectedMsg).forEach((key) => {
|
|
11
|
+
expect(lastMessage, `Message missing property: ${key}`).to.have.property(key);
|
|
12
|
+
if (expectedMsg[key] !== undefined) {
|
|
13
|
+
expect(lastMessage[key], `Message property ${key} does not match`).to.deep.equal(expectedMsg[key]);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
} else {
|
|
17
|
+
expect(lastMessage, 'Message does not match expected value').to.deep.equal(expectedMsg);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static expectError(context: TestContext, expectedError: string | RegExp): void {
|
|
22
|
+
expect(context.errors, 'No errors were recorded').to.have.length.greaterThan(0);
|
|
23
|
+
const error = context.errors[context.errors.length - 1];
|
|
24
|
+
const errorMessage = error?.message || error?.toString() || '';
|
|
25
|
+
|
|
26
|
+
if (typeof expectedError === 'string') {
|
|
27
|
+
expect(errorMessage, 'Error message does not contain expected text').to.include(expectedError);
|
|
28
|
+
} else {
|
|
29
|
+
expect(errorMessage, 'Error message does not match expected pattern').to.match(expectedError);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static expectStatus(context: TestContext, expectedStatus: { fill: string; text?: string }): void {
|
|
34
|
+
expect(context.statuses, 'No status updates were recorded').to.have.length.greaterThan(0);
|
|
35
|
+
const status = context.statuses[context.statuses.length - 1];
|
|
36
|
+
|
|
37
|
+
expect(status.fill, 'Status fill color does not match').to.equal(expectedStatus.fill);
|
|
38
|
+
if (expectedStatus.text) {
|
|
39
|
+
expect(status.text, 'Status text does not match').to.include(expectedStatus.text);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static expectNodeProperty(context: TestContext, property: string, value: any): void {
|
|
44
|
+
expect(context.nodeInstance, 'Node instance does not exist').to.exist;
|
|
45
|
+
expect(context.nodeInstance, `Node missing property: ${property}`).to.have.property(property, value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static expectNoErrors(context: TestContext): void {
|
|
49
|
+
expect(context.errors, 'Unexpected errors occurred').to.have.length(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static expectNoMessages(context: TestContext): void {
|
|
53
|
+
expect(context.messages, 'Unexpected messages were sent').to.have.length(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static expectMessageCount(context: TestContext, count: number): void {
|
|
57
|
+
expect(context.messages, `Expected ${count} messages`).to.have.length(count);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static expectStatusCount(context: TestContext, count: number): void {
|
|
61
|
+
expect(context.statuses, `Expected ${count} status updates`).to.have.length(count);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { NodeAPI } from 'node-red';
|
|
2
|
+
import type { TestScenario, TestContext, MockNodeREDOptions } from './types';
|
|
3
|
+
|
|
4
|
+
// Minimal extension for dependency injection
|
|
5
|
+
export interface DependencyContainer {
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface EnhancedMockNodeREDOptions extends MockNodeREDOptions {
|
|
10
|
+
dependencies?: DependencyContainer;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class NodeTestRunner {
|
|
14
|
+
private static createMockNodeRED(context: TestContext, options: EnhancedMockNodeREDOptions = {}): any {
|
|
15
|
+
return {
|
|
16
|
+
nodes: {
|
|
17
|
+
createNode: function (node: any, config: any) {
|
|
18
|
+
Object.assign(node, config);
|
|
19
|
+
|
|
20
|
+
// Inject dependencies if available
|
|
21
|
+
if (options.dependencies) {
|
|
22
|
+
Object.keys(options.dependencies).forEach((key) => {
|
|
23
|
+
if (!node[key]) {
|
|
24
|
+
node[key] = options.dependencies![key];
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Set up event handlers
|
|
30
|
+
node.on =
|
|
31
|
+
options.onHandler ||
|
|
32
|
+
function (event: string, callback: Function) {
|
|
33
|
+
if (event === 'input') {
|
|
34
|
+
(node as any).inputCallback = callback;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Override node methods to capture calls
|
|
39
|
+
node.send = function (msg: any) {
|
|
40
|
+
context.messages.push(msg);
|
|
41
|
+
if (options.sendHandler) {
|
|
42
|
+
options.sendHandler.call(this, msg);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
node.error = function (error: any) {
|
|
47
|
+
context.errors.push(error);
|
|
48
|
+
if (options.errorHandler) {
|
|
49
|
+
options.errorHandler.call(this, error);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
node.status = function (status: any) {
|
|
54
|
+
context.statuses.push(status);
|
|
55
|
+
if (options.statusHandler) {
|
|
56
|
+
options.statusHandler.call(this, status);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Add logging methods
|
|
61
|
+
node.log = function (msg: any) {
|
|
62
|
+
if (options.logHandler) {
|
|
63
|
+
options.logHandler.call(this, msg);
|
|
64
|
+
}
|
|
65
|
+
// Optionally store logs in context
|
|
66
|
+
if (!context.logs) context.logs = [];
|
|
67
|
+
context.logs.push(msg);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
node.warn = function (msg: any) {
|
|
71
|
+
if (options.warnHandler) {
|
|
72
|
+
options.warnHandler.call(this, msg);
|
|
73
|
+
}
|
|
74
|
+
if (!context.warnings) context.warnings = [];
|
|
75
|
+
context.warnings.push(msg);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
node.debug = function (msg: any) {
|
|
79
|
+
if (options.debugHandler) {
|
|
80
|
+
options.debugHandler.call(this, msg);
|
|
81
|
+
}
|
|
82
|
+
if (!context.debugs) context.debugs = [];
|
|
83
|
+
context.debugs.push(msg);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
node.trace = function (msg: any) {
|
|
87
|
+
if (options.traceHandler) {
|
|
88
|
+
options.traceHandler.call(this, msg);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return node;
|
|
93
|
+
},
|
|
94
|
+
registerType: function (type: string, constructor: Function) {
|
|
95
|
+
(this as any).lastRegisteredType = type;
|
|
96
|
+
(this as any).lastRegisteredConstructor = constructor;
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
util: {
|
|
100
|
+
evaluateNodeProperty: (value: any, type: string, node: any, msg: any): any => {
|
|
101
|
+
switch (type) {
|
|
102
|
+
case 'str':
|
|
103
|
+
return String(value);
|
|
104
|
+
case 'num':
|
|
105
|
+
return Number(value);
|
|
106
|
+
case 'bool':
|
|
107
|
+
return Boolean(value);
|
|
108
|
+
case 'json':
|
|
109
|
+
try {
|
|
110
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
111
|
+
} catch {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
case 'msg':
|
|
115
|
+
const keys = value.split('.');
|
|
116
|
+
let result = msg;
|
|
117
|
+
for (const key of keys) {
|
|
118
|
+
result = result?.[key];
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
case 'flow':
|
|
122
|
+
case 'global':
|
|
123
|
+
return value;
|
|
124
|
+
default:
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private static createTestContext(options: EnhancedMockNodeREDOptions = {}): TestContext {
|
|
133
|
+
const context: TestContext = {
|
|
134
|
+
mockRED: null,
|
|
135
|
+
nodeInstance: null,
|
|
136
|
+
messages: [],
|
|
137
|
+
errors: [],
|
|
138
|
+
statuses: [],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
context.mockRED = this.createMockNodeRED(context, options);
|
|
142
|
+
return context;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
static async runScenario(
|
|
146
|
+
nodeConstructorFn: Function,
|
|
147
|
+
scenario: TestScenario,
|
|
148
|
+
mockOptions: EnhancedMockNodeREDOptions = {},
|
|
149
|
+
): Promise<TestContext> {
|
|
150
|
+
const context = this.createTestContext(mockOptions);
|
|
151
|
+
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let completed = false;
|
|
154
|
+
|
|
155
|
+
const timeout = setTimeout(() => {
|
|
156
|
+
if (!completed) {
|
|
157
|
+
reject(new Error(`Test scenario '${scenario.name}' timed out after ${scenario.timeout || 5000}ms`));
|
|
158
|
+
}
|
|
159
|
+
}, scenario.timeout || 5000);
|
|
160
|
+
|
|
161
|
+
const complete = () => {
|
|
162
|
+
if (completed) return;
|
|
163
|
+
completed = true;
|
|
164
|
+
clearTimeout(timeout);
|
|
165
|
+
// Small delay to catch any additional events
|
|
166
|
+
setTimeout(() => resolve(context), 50);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// Register the node - pass dependencies as second parameter if available
|
|
171
|
+
if (mockOptions.dependencies) {
|
|
172
|
+
nodeConstructorFn(context.mockRED as unknown as NodeAPI, mockOptions.dependencies);
|
|
173
|
+
} else {
|
|
174
|
+
nodeConstructorFn(context.mockRED as unknown as NodeAPI);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Create node instance
|
|
178
|
+
const NodeConstructor = (context.mockRED.nodes as any).lastRegisteredConstructor;
|
|
179
|
+
context.nodeInstance = new NodeConstructor(scenario.config);
|
|
180
|
+
|
|
181
|
+
if ((context.nodeInstance as any).configError) {
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
if (scenario.expectedError && context.errors.length > 0) {
|
|
184
|
+
complete();
|
|
185
|
+
} else if (scenario.expectedStatus && context.statuses.length > 0) {
|
|
186
|
+
complete();
|
|
187
|
+
}
|
|
188
|
+
}, 100);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Set up completion detection based on scenario configuration
|
|
193
|
+
const originalSend = context.nodeInstance.send;
|
|
194
|
+
const originalError = context.nodeInstance.error;
|
|
195
|
+
const originalStatus = context.nodeInstance.status;
|
|
196
|
+
|
|
197
|
+
// Override send to detect expected output
|
|
198
|
+
if (scenario.expectedOutput) {
|
|
199
|
+
context.nodeInstance.send = function (msg: any) {
|
|
200
|
+
originalSend.call(this, msg);
|
|
201
|
+
complete();
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Override error to detect expected errors
|
|
206
|
+
if (scenario.expectedError) {
|
|
207
|
+
let errorCount = context.errors.length;
|
|
208
|
+
context.nodeInstance.error = function (error: any) {
|
|
209
|
+
originalError.call(this, error);
|
|
210
|
+
// ✅ Only complete on NEW errors (not construction errors we already saw)
|
|
211
|
+
if (context.errors.length > errorCount) {
|
|
212
|
+
errorCount = context.errors.length;
|
|
213
|
+
complete();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Override status to detect completion patterns
|
|
219
|
+
if (scenario.expectedStatus) {
|
|
220
|
+
let statusCount = context.statuses.length;
|
|
221
|
+
context.nodeInstance.status = function (status: any) {
|
|
222
|
+
originalStatus.call(this, status);
|
|
223
|
+
|
|
224
|
+
// ✅ Only check NEW statuses
|
|
225
|
+
if (context.statuses.length > statusCount) {
|
|
226
|
+
statusCount = context.statuses.length;
|
|
227
|
+
|
|
228
|
+
const fillMatches =
|
|
229
|
+
!scenario.expectedStatus!.fill || status.fill === scenario.expectedStatus!.fill;
|
|
230
|
+
const shapeMatches =
|
|
231
|
+
!scenario.expectedStatus!.shape || status.shape === scenario.expectedStatus!.shape;
|
|
232
|
+
const textMatches =
|
|
233
|
+
!scenario.expectedStatus!.text || status.text?.includes(scenario.expectedStatus!.text);
|
|
234
|
+
|
|
235
|
+
if (fillMatches && shapeMatches && textMatches) {
|
|
236
|
+
complete();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// If no specific expectations, complete after a short delay
|
|
243
|
+
if (!scenario.expectedOutput && !scenario.expectedError && !scenario.expectedStatus) {
|
|
244
|
+
setTimeout(() => complete(), 100);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Trigger input if provided
|
|
249
|
+
if (scenario.input && (context.nodeInstance as any).inputCallback) {
|
|
250
|
+
setTimeout(() => {
|
|
251
|
+
(context.nodeInstance as any).inputCallback(scenario.input);
|
|
252
|
+
}, 10);
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
reject(error);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { EnhancedMockNodeREDOptions } from './node-test-runner';
|
|
2
|
+
import type { TestScenario } from './types';
|
|
3
|
+
|
|
4
|
+
export class TestScenarioBuilder {
|
|
5
|
+
private scenarios: TestScenario[] = [];
|
|
6
|
+
|
|
7
|
+
addScenario(scenario: TestScenario): this {
|
|
8
|
+
this.scenarios.push(scenario);
|
|
9
|
+
return this;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
addValidScenario(
|
|
13
|
+
name: string,
|
|
14
|
+
config: any,
|
|
15
|
+
input?: any,
|
|
16
|
+
expectedOutput?: any,
|
|
17
|
+
mockOptions?: Partial<EnhancedMockNodeREDOptions>,
|
|
18
|
+
): this {
|
|
19
|
+
return this.addScenario({
|
|
20
|
+
name,
|
|
21
|
+
config,
|
|
22
|
+
input,
|
|
23
|
+
expectedOutput,
|
|
24
|
+
mockOptions,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
addErrorScenario(
|
|
29
|
+
name: string,
|
|
30
|
+
config: any,
|
|
31
|
+
expectedError: any,
|
|
32
|
+
input?: any,
|
|
33
|
+
mockOptions?: Partial<EnhancedMockNodeREDOptions>,
|
|
34
|
+
): this {
|
|
35
|
+
this.scenarios.push({
|
|
36
|
+
name,
|
|
37
|
+
config,
|
|
38
|
+
input,
|
|
39
|
+
expectedError,
|
|
40
|
+
mockOptions,
|
|
41
|
+
});
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addStatusScenario(
|
|
46
|
+
name: string,
|
|
47
|
+
config: any,
|
|
48
|
+
expectedStatus: any,
|
|
49
|
+
input?: any,
|
|
50
|
+
mockOptions?: Partial<EnhancedMockNodeREDOptions>, // Accept full mock options
|
|
51
|
+
): this {
|
|
52
|
+
this.scenarios.push({
|
|
53
|
+
name,
|
|
54
|
+
config,
|
|
55
|
+
input,
|
|
56
|
+
expectedStatus,
|
|
57
|
+
mockOptions,
|
|
58
|
+
});
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
addCustomScenario(scenario: Partial<TestScenario> & { name: string; config: any }): this {
|
|
63
|
+
return this.addScenario(scenario as TestScenario);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getScenarios(): TestScenario[] {
|
|
67
|
+
return [...this.scenarios]; // Return copy to prevent mutation
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
clear(): this {
|
|
71
|
+
this.scenarios = [];
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
}
|