@lhi/n8m 0.2.0 → 0.2.2
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/README.md +105 -6
- package/dist/agentic/graph.d.ts +50 -0
- package/dist/agentic/graph.js +0 -2
- package/dist/agentic/nodes/architect.d.ts +5 -0
- package/dist/agentic/nodes/architect.js +8 -22
- package/dist/agentic/nodes/engineer.d.ts +15 -0
- package/dist/agentic/nodes/engineer.js +25 -4
- package/dist/agentic/nodes/qa.d.ts +1 -0
- package/dist/agentic/nodes/qa.js +280 -45
- package/dist/agentic/nodes/reviewer.d.ts +4 -0
- package/dist/agentic/nodes/reviewer.js +71 -13
- package/dist/agentic/nodes/supervisor.js +2 -3
- package/dist/agentic/state.d.ts +1 -0
- package/dist/agentic/state.js +4 -0
- package/dist/commands/create.js +37 -3
- package/dist/commands/doc.js +1 -1
- package/dist/commands/fixture.d.ts +12 -0
- package/dist/commands/fixture.js +258 -0
- package/dist/commands/test.d.ts +63 -4
- package/dist/commands/test.js +1179 -90
- package/dist/fixture-schema.json +162 -0
- package/dist/resources/node-definitions-fallback.json +185 -8
- package/dist/resources/node-test-hints.json +188 -0
- package/dist/resources/workflow-test-fixtures.json +42 -0
- package/dist/services/ai.service.d.ts +42 -0
- package/dist/services/ai.service.js +271 -21
- package/dist/services/node-definitions.service.d.ts +1 -0
- package/dist/services/node-definitions.service.js +4 -11
- package/dist/utils/config.js +2 -0
- package/dist/utils/fixtureManager.d.ts +28 -0
- package/dist/utils/fixtureManager.js +41 -0
- package/dist/utils/n8nClient.d.ts +27 -0
- package/dist/utils/n8nClient.js +169 -5
- package/dist/utils/spinner.d.ts +17 -0
- package/dist/utils/spinner.js +52 -0
- package/oclif.manifest.json +49 -1
- package/package.json +2 -2
|
@@ -5,6 +5,7 @@ import os from 'os';
|
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { jsonrepair } from 'jsonrepair';
|
|
7
7
|
import { NodeDefinitionsService } from './node-definitions.service.js';
|
|
8
|
+
import { Spinner } from '../utils/spinner.js';
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = path.dirname(__filename);
|
|
10
11
|
export const PROVIDER_PRESETS = {
|
|
@@ -120,31 +121,34 @@ export class AIService {
|
|
|
120
121
|
const model = options.model || (options.provider ? PROVIDER_PRESETS[options.provider]?.defaultModel : this.model);
|
|
121
122
|
const maxRetries = 3;
|
|
122
123
|
let lastError;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
Spinner.start('Thinking');
|
|
125
|
+
try {
|
|
126
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
127
|
+
try {
|
|
128
|
+
if (provider === 'anthropic' && !this.baseURL?.includes('openai') && !this.client) {
|
|
129
|
+
return await this.callAnthropicNative(prompt, model, options);
|
|
130
|
+
}
|
|
131
|
+
const client = this.getClient(provider);
|
|
132
|
+
const completion = await client.chat.completions.create({
|
|
133
|
+
model,
|
|
134
|
+
messages: [{ role: 'user', content: prompt }],
|
|
135
|
+
temperature: options.temperature ?? 0.7,
|
|
136
|
+
});
|
|
137
|
+
const result = completion;
|
|
138
|
+
return result.choices?.[0]?.message?.content || '';
|
|
130
139
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const result = completion;
|
|
138
|
-
return result.choices?.[0]?.message?.content || '';
|
|
139
|
-
}
|
|
140
|
-
catch (error) {
|
|
141
|
-
lastError = error;
|
|
142
|
-
if (attempt < maxRetries) {
|
|
143
|
-
const waitTime = Math.pow(2, attempt) * 1000;
|
|
144
|
-
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
140
|
+
catch (error) {
|
|
141
|
+
lastError = error;
|
|
142
|
+
if (attempt < maxRetries) {
|
|
143
|
+
const waitTime = Math.pow(2, attempt) * 1000;
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
145
|
+
}
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
}
|
|
149
|
+
finally {
|
|
150
|
+
Spinner.stop();
|
|
151
|
+
}
|
|
148
152
|
throw lastError;
|
|
149
153
|
}
|
|
150
154
|
getAlternativeModel() {
|
|
@@ -380,6 +384,69 @@ export class AIService {
|
|
|
380
384
|
return { message: cleanJson };
|
|
381
385
|
}
|
|
382
386
|
}
|
|
387
|
+
async fixExecuteCommandScript(command, error) {
|
|
388
|
+
const errorCtx = error ? `\nError from the failing command:\n${error}\n` : '';
|
|
389
|
+
const prompt = `You are a bash scripting expert.
|
|
390
|
+
The following shell command is used in an n8n Execute Command node.
|
|
391
|
+
It appears that newlines were accidentally stripped from the script, collapsing it to a single line.${errorCtx}
|
|
392
|
+
Current (collapsed) command:
|
|
393
|
+
\`\`\`
|
|
394
|
+
${command}
|
|
395
|
+
\`\`\`
|
|
396
|
+
|
|
397
|
+
Reconstruct the properly-formatted multiline bash script. Rules:
|
|
398
|
+
- Restore newlines between statements (variable assignments, commands, etc.)
|
|
399
|
+
- Properly format line-continuation backslashes: each \\ must be followed by a real newline
|
|
400
|
+
- Keep all original commands and logic intact — do not change what the script does
|
|
401
|
+
- Use \\n to separate statements, not semicolons (unless they were originally there)
|
|
402
|
+
|
|
403
|
+
Return ONLY the fixed shell script. No markdown fences, no explanation.`;
|
|
404
|
+
const response = await this.generateContent(prompt, { temperature: 0.1 });
|
|
405
|
+
return response.replace(/^```(?:bash|sh|shell)?\n?|\n?```$/g, '').trim();
|
|
406
|
+
}
|
|
407
|
+
async fixCodeNodeJavaScript(code, error) {
|
|
408
|
+
const prompt = `You are an n8n Code node JavaScript expert.
|
|
409
|
+
The following code is used in an n8n Code node but fails with a syntax error.
|
|
410
|
+
Error: ${error}
|
|
411
|
+
|
|
412
|
+
Current code:
|
|
413
|
+
\`\`\`javascript
|
|
414
|
+
${code}
|
|
415
|
+
\`\`\`
|
|
416
|
+
|
|
417
|
+
Fix the JavaScript so it runs correctly in an n8n Code node. Rules:
|
|
418
|
+
- All ES6+ syntax is valid (const, let, arrow functions, destructuring, template literals, etc.)
|
|
419
|
+
- Access all input items via: const items = $input.all();
|
|
420
|
+
- Access single input via: const item = $input.first();
|
|
421
|
+
- Return transformed items as: return [{json: {...}}];
|
|
422
|
+
- Do NOT use require() or import — n8n provides built-in variables ($input, $json, $node, etc.)
|
|
423
|
+
|
|
424
|
+
Return ONLY the fixed JavaScript code. No markdown fences, no explanation.`;
|
|
425
|
+
const response = await this.generateContent(prompt, { temperature: 0.1 });
|
|
426
|
+
return response.replace(/^```(?:javascript|js)?\n?|\n?```$/g, '').trim();
|
|
427
|
+
}
|
|
428
|
+
async shimCodeNodeWithMockData(code) {
|
|
429
|
+
const prompt = `You are an n8n Code node JavaScript expert.
|
|
430
|
+
The following n8n Code node makes external HTTP/API calls or references other nodes via $('NodeName') — none of which are available in the isolated test environment.
|
|
431
|
+
Your task: completely rewrite it to return hardcoded mock data that matches the expected output structure.
|
|
432
|
+
|
|
433
|
+
Original code:
|
|
434
|
+
\`\`\`javascript
|
|
435
|
+
${code}
|
|
436
|
+
\`\`\`
|
|
437
|
+
|
|
438
|
+
Rules:
|
|
439
|
+
- Analyze what data structure the original code was meant to return
|
|
440
|
+
- Write a COMPLETE REPLACEMENT — do NOT keep any HTTP requests, fetch, axios, this.helpers calls, or $('NodeName') references
|
|
441
|
+
- Replace every $('NodeName').first().json.X reference with a reasonable hardcoded value
|
|
442
|
+
- Do NOT use require() or import
|
|
443
|
+
- Return realistic hardcoded mock values as: return [{json: {...}}];
|
|
444
|
+
- The mock data must match the real API response shape so downstream nodes work correctly
|
|
445
|
+
|
|
446
|
+
Return ONLY the replacement JavaScript code. No markdown fences, no explanation.`;
|
|
447
|
+
const response = await this.generateContent(prompt, { temperature: 0.1 });
|
|
448
|
+
return response.replace(/^```(?:javascript|js)?\n?|\n?```$/g, '').trim();
|
|
449
|
+
}
|
|
383
450
|
async evaluateCandidates(goal, candidates) {
|
|
384
451
|
if (candidates.length === 0)
|
|
385
452
|
return { selectedIndex: 0, reason: "No candidates" };
|
|
@@ -416,6 +483,189 @@ export class AIService {
|
|
|
416
483
|
return { selectedIndex: 0, reason: "Failed to parse AI response" };
|
|
417
484
|
}
|
|
418
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* AI-powered error evaluation for n8n test executions.
|
|
488
|
+
* Replaces brittle regex classifiers — the model reads the error + node list and decides.
|
|
489
|
+
*/
|
|
490
|
+
async evaluateTestError(errorMessage, workflowNodes, failingNodeName, failingNodeCode) {
|
|
491
|
+
const nodesSummary = (workflowNodes || [])
|
|
492
|
+
.map((n) => `- "${n.name}" (${n.type})`)
|
|
493
|
+
.join('\n');
|
|
494
|
+
const codeContext = failingNodeCode
|
|
495
|
+
? `\nFailing node's JavaScript code:\n\`\`\`javascript\n${failingNodeCode}\n\`\`\``
|
|
496
|
+
: '';
|
|
497
|
+
const prompt = `You are an n8n workflow testing expert. An execution failed with the error below.
|
|
498
|
+
Classify the error and choose the best remediation action.
|
|
499
|
+
|
|
500
|
+
Error: ${errorMessage}
|
|
501
|
+
${failingNodeName ? `Failing node: "${failingNodeName}"` : ''}${codeContext}
|
|
502
|
+
|
|
503
|
+
Workflow nodes:
|
|
504
|
+
${nodesSummary}
|
|
505
|
+
|
|
506
|
+
ACTIONS (choose exactly one):
|
|
507
|
+
• "fix_node" — the error is a fixable node configuration bug in the workflow itself:
|
|
508
|
+
- nodeFixType "code_node_js": JavaScript syntax/runtime error in a Code node
|
|
509
|
+
- nodeFixType "execute_command": shell script error in an Execute Command node
|
|
510
|
+
- nodeFixType "binary_field": wrong binaryPropertyName in a node (HTTP Request always outputs field "data")
|
|
511
|
+
• "regenerate_payload" — the test input payload is missing required fields or has wrong values; the workflow logic itself is correct (e.g. "No property named", "is not defined", "$json.body.X" errors)
|
|
512
|
+
• "structural_pass" — the workflow structure is valid but the test environment lacks:
|
|
513
|
+
external services (Slack, HTTP APIs, OAuth, databases), credentials, upstream binary data, rate limits, or encoding issues (e.g. "could not be parsed").
|
|
514
|
+
Also use this for any URL or connection error from an external API node (Slack, HTTP Request, Google, etc.) — "Invalid URL", "Failed to connect", "ECONNREFUSED", "401 Unauthorized", "403 Forbidden", "Network error" all indicate the test environment can't reach the external service, not a workflow bug.
|
|
515
|
+
IMPORTANT: If the failing code uses $('NodeName') to reference other workflow nodes, and those nodes are AI/LLM nodes, external APIs, or services that cannot run in isolation, this is a structural_pass — the workflow requires the full upstream pipeline to test.
|
|
516
|
+
• "escalate" — fundamental design flaw that requires rebuilding the workflow
|
|
517
|
+
|
|
518
|
+
Respond with ONLY this JSON (no commentary, no markdown):
|
|
519
|
+
{
|
|
520
|
+
"action": "fix_node" | "regenerate_payload" | "structural_pass" | "escalate",
|
|
521
|
+
"nodeFixType": "code_node_js" | "execute_command" | "binary_field" | null,
|
|
522
|
+
"targetNodeName": "<exact node name or null>",
|
|
523
|
+
"suggestedBinaryField": "data" | null,
|
|
524
|
+
"reason": "<one sentence>"
|
|
525
|
+
}`;
|
|
526
|
+
try {
|
|
527
|
+
const response = await this.generateContent(prompt, { temperature: 0.1 });
|
|
528
|
+
const cleanJson = response.replace(/```json\n?|\n?```/g, '').trim();
|
|
529
|
+
const result = JSON.parse(jsonrepair(cleanJson));
|
|
530
|
+
if (!['fix_node', 'regenerate_payload', 'structural_pass', 'escalate'].includes(result.action)) {
|
|
531
|
+
result.action = 'structural_pass';
|
|
532
|
+
}
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
return { action: 'structural_pass', reason: 'Could not evaluate error — defaulting to structural pass' };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Offline-only: evaluates whether a fixed Code node or Execute Command script
|
|
541
|
+
* would succeed given the REAL input items from the fixture's runData.
|
|
542
|
+
* Used when no live re-execution is possible.
|
|
543
|
+
*/
|
|
544
|
+
async evaluateCodeFixOffline(fixedCode, inputItems, originalError, nodeType) {
|
|
545
|
+
const codeLabel = nodeType === 'code_node_js' ? 'JavaScript' : 'shell script';
|
|
546
|
+
const ruleNote = nodeType === 'code_node_js'
|
|
547
|
+
? 'n8n Code node rules: use $input.all() / $input.first(), return Array<{json:{...}}>, no require/import.'
|
|
548
|
+
: 'Shell script runs in the n8n Execute Command node environment.';
|
|
549
|
+
const prompt = `You are an n8n ${codeLabel} execution expert.
|
|
550
|
+
|
|
551
|
+
A node previously failed with this error:
|
|
552
|
+
${originalError}
|
|
553
|
+
|
|
554
|
+
It was fixed. The fixed ${codeLabel} is:
|
|
555
|
+
\`\`\`
|
|
556
|
+
${fixedCode}
|
|
557
|
+
\`\`\`
|
|
558
|
+
|
|
559
|
+
The REAL input items from the fixture are:
|
|
560
|
+
${JSON.stringify(inputItems, null, 2)}
|
|
561
|
+
|
|
562
|
+
${ruleNote}
|
|
563
|
+
|
|
564
|
+
Task: Mentally execute the fixed code against these real inputs.
|
|
565
|
+
- Does it have syntax errors?
|
|
566
|
+
- Are all referenced fields present in the input items?
|
|
567
|
+
- Does it address the original error?
|
|
568
|
+
- Would it produce valid output?
|
|
569
|
+
|
|
570
|
+
Respond with ONLY this JSON (no commentary, no markdown):
|
|
571
|
+
{"wouldPass": true|false, "reason": "<one sentence>"}`;
|
|
572
|
+
try {
|
|
573
|
+
const response = await this.generateContent(prompt, { temperature: 0.1 });
|
|
574
|
+
const cleanJson = response.replace(/```json\n?|\n?```/g, '').trim();
|
|
575
|
+
const result = JSON.parse(jsonrepair(cleanJson));
|
|
576
|
+
return { wouldPass: Boolean(result.wouldPass), reason: result.reason ?? '' };
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return { wouldPass: true, reason: 'Could not evaluate offline — assuming pass' };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Traces binary data flow through an entire workflow graph to find the correct
|
|
584
|
+
* binary field name for a failing upload node.
|
|
585
|
+
*
|
|
586
|
+
* Handles passthrough nodes (Merge, Set, IF, Switch) by tracing further upstream
|
|
587
|
+
* and reads Code node jsCode to extract the actual binary field assignment.
|
|
588
|
+
* Delegates graph traversal + analysis entirely to the AI.
|
|
589
|
+
*/
|
|
590
|
+
async inferBinaryFieldNameFromWorkflow(failingNodeName, workflowNodes, workflowConnections) {
|
|
591
|
+
const nodesSummary = (workflowNodes || [])
|
|
592
|
+
.map((n) => `- "${n.name}" (${n.type})`)
|
|
593
|
+
.join('\n');
|
|
594
|
+
const connsSummary = Object.entries(workflowConnections || {})
|
|
595
|
+
.map(([src, targets]) => {
|
|
596
|
+
const dests = (targets.main || []).flat()
|
|
597
|
+
.map((c) => `"${c?.node}"`)
|
|
598
|
+
.filter(Boolean)
|
|
599
|
+
.join(', ');
|
|
600
|
+
return `"${src}" → [${dests}]`;
|
|
601
|
+
})
|
|
602
|
+
.join('\n');
|
|
603
|
+
// Include full jsCode for any Code/Function nodes so the AI can read binary assignments
|
|
604
|
+
const codeSnippets = (workflowNodes || [])
|
|
605
|
+
.filter((n) => (n.type === 'n8n-nodes-base.code' || n.type === 'n8n-nodes-base.function') &&
|
|
606
|
+
n.parameters?.jsCode)
|
|
607
|
+
.map((n) => `\n"${n.name}" (${n.type}) jsCode:\n\`\`\`javascript\n${n.parameters.jsCode}\n\`\`\``)
|
|
608
|
+
.join('\n');
|
|
609
|
+
const prompt = `You are an n8n binary data expert. Analyze this workflow to find the correct binary field name for the failing upload node.
|
|
610
|
+
|
|
611
|
+
Failing node: "${failingNodeName}" — error: "has no binary field"
|
|
612
|
+
|
|
613
|
+
Workflow nodes:
|
|
614
|
+
${nodesSummary}
|
|
615
|
+
|
|
616
|
+
Connections (source → [targets]):
|
|
617
|
+
${connsSummary}
|
|
618
|
+
${codeSnippets ? `\nCode node implementations:${codeSnippets}` : ''}
|
|
619
|
+
|
|
620
|
+
Task: Trace binary data flow backwards from "${failingNodeName}" to find the node that actually CREATES or DOWNLOADS the binary data. Then determine what field name it uses for the binary output.
|
|
621
|
+
|
|
622
|
+
Binary field name rules:
|
|
623
|
+
- n8n-nodes-base.httpRequest → always outputs binary as "data"
|
|
624
|
+
- n8n-nodes-base.readBinaryFile / readBinaryFiles → "data"
|
|
625
|
+
- n8n-nodes-base.code / function → look at jsCode: items[0].binary = { FIELD_NAME: ... } or return [{ binary: { FIELD_NAME: ... } }]
|
|
626
|
+
- n8n-nodes-base.merge / set / if / switch / noOp → pass-through nodes, trace upstream
|
|
627
|
+
- Slack / Google Drive / Dropbox / other API download nodes → "data"
|
|
628
|
+
|
|
629
|
+
What is the correct binaryPropertyName for "${failingNodeName}"?
|
|
630
|
+
If you cannot determine it with confidence, return null.
|
|
631
|
+
|
|
632
|
+
Respond ONLY with this JSON (no commentary, no markdown):
|
|
633
|
+
{"binaryFieldName": "the_field_name" | null}`;
|
|
634
|
+
try {
|
|
635
|
+
const response = await this.generateContent(prompt, { temperature: 0.1 });
|
|
636
|
+
const cleanJson = response.replace(/```json\n?|\n?```/g, '').trim();
|
|
637
|
+
const result = JSON.parse(jsonrepair(cleanJson));
|
|
638
|
+
return typeof result.binaryFieldName === 'string' ? result.binaryFieldName : null;
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Returns jsCode for an n8n Code node (runOnceForAllItems) that produces
|
|
646
|
+
* synthetic binary test data in the specified field.
|
|
647
|
+
*
|
|
648
|
+
* Hardcoded — no LLM call — because the n8n Code node binary format is
|
|
649
|
+
* deterministic and LLM-generated variants consistently misuse APIs that
|
|
650
|
+
* aren't available (this.helpers.prepareBinaryData, $input.all() in wrong mode, etc.).
|
|
651
|
+
*/
|
|
652
|
+
generateBinaryShimCode(binaryFieldName) {
|
|
653
|
+
const fieldKey = JSON.stringify(binaryFieldName);
|
|
654
|
+
return [
|
|
655
|
+
`const base64 = Buffer.from('n8m-test-binary', 'utf-8').toString('base64');`,
|
|
656
|
+
`return $input.all().map(item => ({`,
|
|
657
|
+
` json: item.json,`,
|
|
658
|
+
` binary: {`,
|
|
659
|
+
` ${fieldKey}: {`,
|
|
660
|
+
` data: base64,`,
|
|
661
|
+
` mimeType: 'text/plain',`,
|
|
662
|
+
` fileName: 'test-file.txt',`,
|
|
663
|
+
` fileExtension: 'txt'`,
|
|
664
|
+
` }`,
|
|
665
|
+
` }`,
|
|
666
|
+
`}));`,
|
|
667
|
+
].join('\n');
|
|
668
|
+
}
|
|
419
669
|
/**
|
|
420
670
|
* Generates 3-5 diverse test scenarios (input payloads) for a workflow.
|
|
421
671
|
*/
|
|
@@ -9,9 +9,11 @@ export class NodeDefinitionsService {
|
|
|
9
9
|
static instance;
|
|
10
10
|
definitions = [];
|
|
11
11
|
client;
|
|
12
|
+
defaultClient;
|
|
12
13
|
constructor() {
|
|
13
14
|
// Will be overridden in loadDefinitions() once config is available
|
|
14
15
|
this.client = new N8nClient();
|
|
16
|
+
this.defaultClient = this.client;
|
|
15
17
|
}
|
|
16
18
|
static getInstance() {
|
|
17
19
|
if (!NodeDefinitionsService.instance) {
|
|
@@ -26,27 +28,21 @@ export class NodeDefinitionsService {
|
|
|
26
28
|
async loadDefinitions() {
|
|
27
29
|
if (this.definitions.length > 0)
|
|
28
30
|
return;
|
|
29
|
-
console.log('Loading node definitions...');
|
|
30
31
|
try {
|
|
31
32
|
// Re-initialize client if env vars changed (e.g. after config load)
|
|
32
33
|
const config = await ConfigManager.load();
|
|
33
34
|
// Env vars take priority over stored config
|
|
34
35
|
const apiUrl = process.env.N8N_API_URL || config.n8nUrl;
|
|
35
36
|
const apiKey = process.env.N8N_API_KEY || config.n8nKey;
|
|
36
|
-
if (apiUrl && apiKey) {
|
|
37
|
+
if (apiUrl && apiKey && this.client === this.defaultClient) {
|
|
37
38
|
this.client = new N8nClient({ apiUrl, apiKey });
|
|
38
39
|
}
|
|
39
40
|
this.definitions = await this.client.getNodeTypes();
|
|
40
41
|
if (this.definitions.length === 0) {
|
|
41
|
-
console.warn("No node definitions returned from n8n instance. Attempting fallback...");
|
|
42
42
|
this.loadFallback();
|
|
43
43
|
}
|
|
44
|
-
else {
|
|
45
|
-
console.log(`Loaded ${this.definitions.length} node definitions.`);
|
|
46
|
-
}
|
|
47
44
|
}
|
|
48
45
|
catch {
|
|
49
|
-
console.error("Failed to load node definitions from n8n instance (fetch failed).");
|
|
50
46
|
this.loadFallback();
|
|
51
47
|
}
|
|
52
48
|
}
|
|
@@ -67,15 +63,12 @@ export class NodeDefinitionsService {
|
|
|
67
63
|
if (fallbackPath) {
|
|
68
64
|
const fallbackData = fs.readFileSync(fallbackPath, 'utf8');
|
|
69
65
|
this.definitions = JSON.parse(fallbackData);
|
|
70
|
-
console.log(`Loaded ${this.definitions.length} node definitions (from fallback at ${path.basename(path.dirname(fallbackPath))}).`);
|
|
71
66
|
}
|
|
72
67
|
else {
|
|
73
|
-
console.warn("Fallback node definitions file not found in searched locations.");
|
|
74
68
|
this.definitions = [];
|
|
75
69
|
}
|
|
76
70
|
}
|
|
77
|
-
catch
|
|
78
|
-
console.error("Failed to load fallback node definitions:", fallbackError);
|
|
71
|
+
catch {
|
|
79
72
|
this.definitions = [];
|
|
80
73
|
}
|
|
81
74
|
}
|
package/dist/utils/config.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
4
5
|
export class ConfigManager {
|
|
5
6
|
static configDir = path.join(os.homedir(), '.n8m');
|
|
6
7
|
static configFile = path.join(os.homedir(), '.n8m', 'config.json');
|
|
7
8
|
static async load() {
|
|
9
|
+
dotenv.config({ quiet: true }); // Load .env from cwd if present (no-op if already loaded or file missing)
|
|
8
10
|
try {
|
|
9
11
|
const data = await fs.readFile(this.configFile, 'utf-8');
|
|
10
12
|
return JSON.parse(data);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface WorkflowFixture {
|
|
2
|
+
version: '1.0';
|
|
3
|
+
capturedAt: string;
|
|
4
|
+
workflowId: string;
|
|
5
|
+
workflowName: string;
|
|
6
|
+
workflow: any;
|
|
7
|
+
execution: {
|
|
8
|
+
id?: string;
|
|
9
|
+
status: string;
|
|
10
|
+
startedAt?: string;
|
|
11
|
+
data: {
|
|
12
|
+
resultData: {
|
|
13
|
+
error?: any;
|
|
14
|
+
runData: Record<string, any[]>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare class FixtureManager {
|
|
20
|
+
private fixturesDir;
|
|
21
|
+
constructor();
|
|
22
|
+
private fixturePath;
|
|
23
|
+
exists(workflowId: string): boolean;
|
|
24
|
+
load(workflowId: string): WorkflowFixture | null;
|
|
25
|
+
loadFromPath(filePath: string): WorkflowFixture | null;
|
|
26
|
+
getCapturedDate(workflowId: string): Date | null;
|
|
27
|
+
save(fixture: WorkflowFixture): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
export class FixtureManager {
|
|
5
|
+
fixturesDir;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.fixturesDir = path.join(process.cwd(), '.n8m', 'fixtures');
|
|
8
|
+
}
|
|
9
|
+
fixturePath(workflowId) {
|
|
10
|
+
return path.join(this.fixturesDir, `${workflowId}.json`);
|
|
11
|
+
}
|
|
12
|
+
exists(workflowId) {
|
|
13
|
+
return existsSync(this.fixturePath(workflowId));
|
|
14
|
+
}
|
|
15
|
+
load(workflowId) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = readFileSync(this.fixturePath(workflowId), 'utf-8');
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
loadFromPath(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(path.resolve(filePath), 'utf-8');
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
getCapturedDate(workflowId) {
|
|
34
|
+
const fixture = this.load(workflowId);
|
|
35
|
+
return fixture ? new Date(fixture.capturedAt) : null;
|
|
36
|
+
}
|
|
37
|
+
async save(fixture) {
|
|
38
|
+
await fs.mkdir(this.fixturesDir, { recursive: true });
|
|
39
|
+
await fs.writeFile(this.fixturePath(fixture.workflowId), JSON.stringify(fixture, null, 2), 'utf-8');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -90,8 +90,35 @@ export declare class N8nClient {
|
|
|
90
90
|
* Uses a Webhook to allow actual activation (Manual triggers don't allow activation).
|
|
91
91
|
*/
|
|
92
92
|
injectManualTrigger(workflowData: any): any;
|
|
93
|
+
/**
|
|
94
|
+
* Temporarily set pin data on a workflow so test executions receive
|
|
95
|
+
* synthetic binary data instead of running binary-generating nodes live.
|
|
96
|
+
* Pass pinData:{} to clear injected pins and restore the original state.
|
|
97
|
+
*
|
|
98
|
+
* The public /api/v1/ schema may reject pinData as an "additional property".
|
|
99
|
+
* In that case we fall back to the internal /rest/ API that n8n's own UI uses,
|
|
100
|
+
* which accepts pinData without schema restriction.
|
|
101
|
+
*/
|
|
102
|
+
setPinData(workflowId: string, workflowData: any, pinData: Record<string, any[]>): Promise<void>;
|
|
93
103
|
/**
|
|
94
104
|
* Get n8n instance deep link for a workflow
|
|
95
105
|
*/
|
|
96
106
|
getWorkflowLink(workflowId: string): string;
|
|
107
|
+
/**
|
|
108
|
+
* Node types that never make external HTTP calls — pure logic, control flow,
|
|
109
|
+
* data transformation, or local execution. Everything else is a candidate
|
|
110
|
+
* for shimming during test runs.
|
|
111
|
+
*/
|
|
112
|
+
private static readonly PASS_THROUGH_TYPES;
|
|
113
|
+
/**
|
|
114
|
+
* Replace every node that makes external network calls with an inert Code shim
|
|
115
|
+
* returning plausible fake data. Node NAMES (and IDs) are preserved so
|
|
116
|
+
* connections stay valid. Used to ensure tests never hit real external services.
|
|
117
|
+
*
|
|
118
|
+
* Shimming criteria: the node has credentials configured OR is an HTTP Request node.
|
|
119
|
+
* Pure-logic nodes in PASS_THROUGH_TYPES are always left untouched.
|
|
120
|
+
*/
|
|
121
|
+
static shimNetworkNodes(nodes: any[]): any[];
|
|
122
|
+
/** Generate the JS body for a Code shim that stands in for an external-calling node. */
|
|
123
|
+
static buildNetworkShimCode(nodeType: string): string;
|
|
97
124
|
}
|