@blucca/n8n-check 0.0.0-stage → 0.1.6

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.
Files changed (70) hide show
  1. package/Dockerfile +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +319 -2
  4. package/bin/n8n-check.mjs +62 -0
  5. package/examples/agregado-enrichment/README.md +68 -0
  6. package/examples/agregado-enrichment/n8n-synthetic +11 -0
  7. package/examples/agregado-enrichment/observed-results.json +806 -0
  8. package/examples/agregado-enrichment/prepare.mjs +39 -0
  9. package/examples/identity-projection/README.md +39 -0
  10. package/examples/identity-projection/case.json +33 -0
  11. package/examples/identity-projection/observed-results.json +117 -0
  12. package/examples/identity-projection/workflow.json +71 -0
  13. package/examples/identity-projection/wrong-id.json +71 -0
  14. package/examples/morsof-invoice-history/README.md +77 -0
  15. package/examples/morsof-invoice-history/UPSTREAM-LICENSE +21 -0
  16. package/examples/morsof-invoice-history/n8n-datatable.cjs +28 -0
  17. package/examples/morsof-invoice-history/observed-results.json +113 -0
  18. package/examples/morsof-invoice-history/prepare.mjs +57 -0
  19. package/examples/morsof-invoice-history/replay.mjs +35 -0
  20. package/examples/morsof-invoice-history/run.sh +19 -0
  21. package/examples/object-body/broken.json +74 -0
  22. package/examples/object-body/case.json +81 -0
  23. package/examples/object-body/fixed.json +74 -0
  24. package/examples/object-body/observed-results.json +449 -0
  25. package/examples/object-body/retry-case.json +112 -0
  26. package/examples/object-body/retry.json +77 -0
  27. package/examples/propfind-body/README.md +75 -0
  28. package/examples/propfind-body/case.json +51 -0
  29. package/examples/propfind-body/no-body-control.json +96 -0
  30. package/examples/propfind-body/observed-results.json +307 -0
  31. package/examples/propfind-body/workflow.json +96 -0
  32. package/examples/raw-response/README.md +71 -0
  33. package/examples/raw-response/file-case.json +54 -0
  34. package/examples/raw-response/json-body.json +58 -0
  35. package/examples/raw-response/json-case.json +52 -0
  36. package/examples/raw-response/observed-results.json +383 -0
  37. package/examples/raw-response/raw-file.json +88 -0
  38. package/examples/raw-response/raw-json.json +58 -0
  39. package/examples/raw-response/raw-text.json +58 -0
  40. package/examples/raw-response/text-case.json +50 -0
  41. package/examples/retry-isolation/README.md +71 -0
  42. package/examples/retry-isolation/direct-case.json +112 -0
  43. package/examples/retry-isolation/direct.json +101 -0
  44. package/examples/retry-isolation/editor-template.json +168 -0
  45. package/examples/retry-isolation/http-batching-case.json +112 -0
  46. package/examples/retry-isolation/http-batching.json +107 -0
  47. package/examples/retry-isolation/loop-case.json +117 -0
  48. package/examples/retry-isolation/loop.json +133 -0
  49. package/examples/retry-isolation/observed-results.json +155 -0
  50. package/examples/silent-filter/README.md +57 -0
  51. package/examples/silent-filter/broken.json +96 -0
  52. package/examples/silent-filter/case.json +120 -0
  53. package/examples/silent-filter/fixed.json +96 -0
  54. package/examples/silent-filter/observed-results.json +273 -0
  55. package/examples/stop-after/README.md +66 -0
  56. package/examples/stop-after/broken.json +134 -0
  57. package/examples/stop-after/case.json +34 -0
  58. package/examples/stop-after/fixed.json +134 -0
  59. package/examples/stop-after/observed-results.json +162 -0
  60. package/examples/version-matrix/README.md +65 -0
  61. package/examples/version-matrix/observed-results.json +307 -0
  62. package/examples/version-matrix/upgrade-check.yml +32 -0
  63. package/package.json +41 -4
  64. package/src/action.mjs +108 -0
  65. package/src/case-draft.mjs +163 -0
  66. package/src/mock.mjs +72 -0
  67. package/src/report.mjs +73 -0
  68. package/src/runner.mjs +111 -0
  69. package/src/version.mjs +3 -0
  70. package/src/workflow.mjs +140 -0
@@ -0,0 +1,140 @@
1
+ const randomUUID = () => globalThis.crypto.randomUUID();
2
+
3
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value);
4
+ const ensure = (condition, message) => { if (!condition) throw new Error(message); };
5
+ export function validateCase(spec) {
6
+ ensure(plain(spec) && spec.version === 1, 'Case version must be 1');
7
+ ensure(typeof spec.name === 'string' && spec.name.length, 'Case name is required');
8
+ ensure(plain(spec.input) && typeof spec.input.node === 'string', 'input.node is required');
9
+ ensure(spec.stopAfter === undefined || (typeof spec.stopAfter === 'string' && spec.stopAfter.length > 0), 'stopAfter must be a non-empty node name');
10
+ ensure(Array.isArray(spec.input.items) && spec.input.items.every(plain), 'input.items must be an array of JSON objects');
11
+ ensure(Array.isArray(spec.mocks), 'mocks must be an array (use [] for local-only workflows)');
12
+ const names = new Set();
13
+ for (const mock of spec.mocks) {
14
+ ensure(typeof mock.node === 'string' && !names.has(mock.node), 'Each mock must name a unique HTTP Request node');
15
+ names.add(mock.node);
16
+ ensure(typeof mock.url === 'string' && mock.url.startsWith('/') && !mock.url.startsWith('//'), `Mock ${mock.node}: url must be a local path, optionally with n8n {{ expressions }}`);
17
+ ensure(Array.isArray(mock.routes) && mock.routes.length, `Mock ${mock.node}: routes are required`);
18
+ const routes = new Set();
19
+ for (const route of mock.routes) {
20
+ ensure(typeof route.method === 'string' && /^[A-Z]+$/.test(route.method), 'Route method must be uppercase');
21
+ ensure(typeof route.path === 'string' && route.path.startsWith('/'), 'Route path must start with /');
22
+ const key = `${route.method} ${route.path}`;
23
+ ensure(!routes.has(key), `Duplicate route: ${key}`); routes.add(key);
24
+ ensure(Array.isArray(route.responses) && route.responses.length, `Route ${key}: responses are required`);
25
+ for (const response of route.responses) {
26
+ ensure(plain(response) && Number.isInteger(response.status) && response.status >= 200 && response.status <= 599, `Route ${key}: response status must be 200–599`);
27
+ ensure(Object.hasOwn(response, 'json'), `Route ${key}: response.json is required`);
28
+ ensure(response.headers === undefined || (plain(response.headers) && Object.values(response.headers).every(v => typeof v === 'string')), `Route ${key}: headers must contain strings`);
29
+ }
30
+ ensure(plain(route.expect) && Number.isInteger(route.expect.count) && route.expect.count >= 0, `Route ${key}: expect.count must be a non-negative integer`);
31
+ ensure(route.expect.bodies === undefined || (Array.isArray(route.expect.bodies) && route.expect.bodies.length === route.expect.count), `Route ${key}: expect.bodies length must match expect.count`);
32
+ }
33
+ }
34
+ ensure(Array.isArray(spec.assertions) && spec.assertions.length, 'At least one output assertion is required');
35
+ for (const assertion of spec.assertions) {
36
+ ensure(plain(assertion) && typeof assertion.node === 'string' && assertion.node.length > 0, 'Each assertion needs a non-empty node name');
37
+ ensure(assertion.equals !== undefined || assertion.count !== undefined, 'Each assertion needs equals or count');
38
+ ensure(assertion.equals === undefined || Array.isArray(assertion.equals), 'assertion.equals must be an array');
39
+ ensure(assertion.count === undefined || (Number.isInteger(assertion.count) && assertion.count >= 0), 'assertion.count must be a non-negative integer');
40
+ ensure(assertion.pluck === undefined || (typeof assertion.pluck === 'string' && assertion.pluck.split('.').every(part => part.length > 0)), 'assertion.pluck must be a non-empty dot-separated path');
41
+ ensure(assertion.pluck === undefined || Array.isArray(assertion.equals), 'assertion.pluck requires equals');
42
+ ensure(assertion.output === undefined || (Number.isInteger(assertion.output) && assertion.output >= 0), 'assertion.output must be a non-negative integer');
43
+ }
44
+ ensure(spec.timeoutMs === undefined || (Number.isInteger(spec.timeoutMs) && spec.timeoutMs >= 1000 && spec.timeoutMs <= 600000), 'timeoutMs must be 1000–600000');
45
+ ensure(spec.maxRequests === undefined || (Number.isInteger(spec.maxRequests) && spec.maxRequests > 0 && spec.maxRequests <= 10000), 'maxRequests must be 1–10000');
46
+ return spec;
47
+ }
48
+
49
+ const edges = connections => Object.entries(connections ?? {}).flatMap(([from, types]) =>
50
+ Object.entries(types).flatMap(([type, branches]) => branches.flatMap(branch => branch.map(edge => ({ from, type, ...edge })))));
51
+
52
+ /** Select the execution graph; sibling branches remain reachable after a stop. */
53
+ export function executionSlice(source, inputNode, stopAfter) {
54
+ const byName = new Map(source.nodes.map(node => [node.name, node]));
55
+ ensure(byName.has(inputNode), `Input node not found: ${inputNode}`);
56
+ const links = edges(source.connections);
57
+ const reachable = (start, stop) => {
58
+ const found = new Set([start]);
59
+ for (const name of found) if (name !== stop) for (const edge of links) if (edge.from === name) found.add(edge.node);
60
+ return found;
61
+ };
62
+ if (stopAfter !== undefined) {
63
+ ensure(typeof stopAfter === 'string' && stopAfter.length > 0, 'stopAfter must be a non-empty node name');
64
+ ensure(byName.has(stopAfter), `stopAfter node not found: ${stopAfter}`);
65
+ ensure(reachable(inputNode).has(stopAfter), `stopAfter node "${stopAfter}" must be reachable from input "${inputNode}".`);
66
+ const afterStop = new Set(links.filter(edge => edge.from === stopAfter).map(edge => edge.node));
67
+ for (const name of afterStop) for (const edge of links) if (edge.from === name) afterStop.add(edge.node);
68
+ ensure(!afterStop.has(stopAfter), `stopAfter node "${stopAfter}" lies inside a loop. Choose a node after the loop's completed output.`);
69
+ }
70
+ const kept = reachable(inputNode, stopAfter);
71
+ for (const edge of links) if (kept.has(edge.from) && edge.from !== stopAfter) {
72
+ ensure(byName.has(edge.node), `Connection from ${edge.from} references missing node: ${edge.node}`);
73
+ }
74
+ return { kept, links };
75
+ }
76
+
77
+ export function prepareWorkflow(source, spec, mockBase) {
78
+ validateCase(spec);
79
+ if (Array.isArray(source)) {
80
+ ensure(source.length === 1, 'Supply one workflow export; received an array with multiple workflows');
81
+ source = source[0];
82
+ }
83
+ ensure(plain(source) && Array.isArray(source.nodes) && plain(source.connections), 'Expected an n8n workflow export with nodes and connections');
84
+ const workflow = structuredClone(source);
85
+ const byName = new Map(workflow.nodes.map(node => [node.name, node]));
86
+ ensure(byName.size === workflow.nodes.length, 'Workflow node names must be unique');
87
+ const { kept, links } = executionSlice(workflow, spec.input.node, spec.stopAfter);
88
+ for (const edge of links) {
89
+ if (kept.has(edge.node) && edge.node !== spec.input.node && !kept.has(edge.from)) {
90
+ throw new Error(`Slice needs incoming ${edge.type} connection from "${edge.from}" to "${edge.node}". Move input.node earlier or provide a self-contained workflow slice.`);
91
+ }
92
+ }
93
+ const changes = [{ node: spec.input.node, change: 'replace with fixture JSON items' }];
94
+ if (spec.stopAfter !== undefined) changes.push({ node: spec.stopAfter, change: 'stop this branch after the node; remove outgoing connections; retain independently reachable branches' });
95
+ const fixture = byName.get(spec.input.node);
96
+ const fixtureNode = {
97
+ id: fixture.id || randomUUID(), name: fixture.name, position: fixture.position || [0, 0],
98
+ type: 'n8n-nodes-base.code', typeVersion: 2,
99
+ parameters: { mode: 'runOnceForAllItems', jsCode: `return JSON.parse(${JSON.stringify(JSON.stringify(spec.input.items))}).map(json => ({json}));` },
100
+ };
101
+ workflow.nodes = workflow.nodes.filter(node => kept.has(node.name)).map(node => node.name === fixture.name ? fixtureNode : node);
102
+ workflow.connections = Object.fromEntries(Object.entries(workflow.connections).filter(([name]) => kept.has(name) && name !== spec.stopAfter).map(([name, types]) => [name,
103
+ Object.fromEntries(Object.entries(types).map(([type, branches]) => [type, branches.map(branch => branch.filter(edge => kept.has(edge.node)))])),
104
+ ]));
105
+ for (const edge of links) ensure(!(kept.has(edge.from) && edge.node === fixture.name), `Input node "${fixture.name}" lies inside a loop. Choose a fixture boundary before the loop.`);
106
+ let triggerName = 'n8n-check start';
107
+ while (byName.has(triggerName)) triggerName += '_';
108
+ workflow.nodes.unshift({ id: randomUUID(), name: triggerName, type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [-400, 0], parameters: {} });
109
+ workflow.connections[triggerName] = { main: [[{ node: fixture.name, type: 'main', index: 0 }]] };
110
+ const mockNames = new Set(spec.mocks.map(mock => mock.node));
111
+ for (let index = 0; index < spec.mocks.length; index++) {
112
+ const mock = spec.mocks[index];
113
+ const node = workflow.nodes.find(n => n.name === mock.node);
114
+ ensure(node?.type === 'n8n-nodes-base.httpRequest', `Mock node must be an HTTP Request node inside the slice: ${mock.node}`);
115
+ delete node.credentials;
116
+ node.parameters.authentication = 'none';
117
+ delete node.parameters.genericAuthType;
118
+ delete node.parameters.nodeCredentialType;
119
+ node.parameters.url = `${mock.url.includes('{{') ? '=' : ''}${mockBase}/${index}${mock.url}`;
120
+ changes.push({ node: node.name, change: 'route URL to local mock; remove credential references; authentication=none' });
121
+ }
122
+ for (const node of workflow.nodes) {
123
+ ensure(!node.credentials || !Object.keys(node.credentials).length, `Node "${node.name}" uses credentials. Replace this boundary with fixture input or an HTTP mock.`);
124
+ ensure(node.type !== 'n8n-nodes-base.httpRequest' || mockNames.has(node.name), `HTTP Request node "${node.name}" needs a mock entry`);
125
+ for (const match of JSON.stringify(node.parameters).matchAll(/(?:\$\(|\$items\(|\$node\[)\s*(?:\\?")([^"\\]+)(?:\\?")|(?:\$\(|\$items\(|\$node\[)\s*'([^']+)'/g)) {
126
+ const dependency = match[1] ?? match[2];
127
+ ensure(kept.has(dependency), `Node "${node.name}" references "${dependency}" outside the slice. Move input.node earlier or provide a self-contained workflow slice.`);
128
+ }
129
+ }
130
+ for (const assertion of spec.assertions) ensure(kept.has(assertion.node), `Assertion node outside the slice: ${assertion.node}`);
131
+ // Build a fresh workflow record: ownership, pinData, staticData and production settings stay in the source.
132
+ return {
133
+ workflow: {
134
+ id: randomUUID().replaceAll('-', '').slice(0, 16), name: `n8n-check: ${spec.name}`, active: false,
135
+ nodes: workflow.nodes, connections: workflow.connections,
136
+ settings: { ...(source.settings?.timezone ? { timezone: source.settings.timezone } : {}), executionOrder: source.settings?.executionOrder || 'v1', executionTimeout: Math.ceil((spec.timeoutMs ?? 60000) / 1000), saveExecutionProgress: true },
137
+ },
138
+ changes, omittedNodes: source.nodes.filter(node => !kept.has(node.name)).map(node => node.name),
139
+ };
140
+ }