Merge remote-tracking branch 'origin/master' into sec-143-cross-site-scripting-cwe-79

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-10-22 12:54:02 +02:00
commit 22440937bf
No known key found for this signature in database
360 changed files with 16421 additions and 1777 deletions

View file

@ -41,6 +41,11 @@ on:
description: 'PR number to run tests for.'
required: false
type: number
node_view_version:
description: 'Node View version to run tests with.'
required: false
default: '1'
type: string
secrets:
CYPRESS_RECORD_KEY:
description: 'Cypress record key.'
@ -160,6 +165,7 @@ jobs:
spec: '${{ inputs.spec }}'
env:
NODE_OPTIONS: --dns-result-order=ipv4first
CYPRESS_NODE_VIEW_VERSION: ${{ inputs.node_view_version }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_TESTS: true

View file

@ -27,6 +27,11 @@ on:
description: 'URL to call after workflow is done.'
required: false
default: ''
node_view_version:
description: 'Node View version to run tests with.'
required: false
default: '1'
type: string
jobs:
calls-start-url:
@ -46,6 +51,7 @@ jobs:
branch: ${{ github.event.inputs.branch || 'master' }}
user: ${{ github.event.inputs.user || 'PR User' }}
spec: ${{ github.event.inputs.spec || 'e2e/*' }}
node_view_version: ${{ github.event.inputs.node_view_version || '1' }}
secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

View file

@ -1,3 +1,42 @@
# [1.64.0](https://github.com/n8n-io/n8n/compare/n8n@1.63.0...n8n@1.64.0) (2024-10-16)
### Bug Fixes
* Adjust arrow button colors in dark mode ([#11248](https://github.com/n8n-io/n8n/issues/11248)) ([439132c](https://github.com/n8n-io/n8n/commit/439132c291a812d57702c94eaa12878394ac4c69))
* **core:** Ensure error reporter does not promote `info` to `error` messages ([#11245](https://github.com/n8n-io/n8n/issues/11245)) ([a7fc7fc](https://github.com/n8n-io/n8n/commit/a7fc7fc22997acec86dc94386c95349fd018f4ae))
* **core:** Override executions mode if `regular` during worker startup ([#11250](https://github.com/n8n-io/n8n/issues/11250)) ([c0aa28c](https://github.com/n8n-io/n8n/commit/c0aa28c6cf3f77b04e04663217c9df8e3803ed3f))
* **core:** Wrap nodes for tool use at a suitable time ([#11238](https://github.com/n8n-io/n8n/issues/11238)) ([c2fb881](https://github.com/n8n-io/n8n/commit/c2fb881d61291209802438d95892d052f5c82d43))
* Don't show pinned data tooltip for pinned nodes ([#11249](https://github.com/n8n-io/n8n/issues/11249)) ([c2ad156](https://github.com/n8n-io/n8n/commit/c2ad15646d326a8f71e314d54efe202a5bcdd296))
* **editor:** Bring back the "Forgot password" link on SigninView ([#11216](https://github.com/n8n-io/n8n/issues/11216)) ([4e78c46](https://github.com/n8n-io/n8n/commit/4e78c46a7450c7fc0694369944d4fb446cef2348))
* **editor:** Fix chat crashing when rendering output-parsed content ([#11210](https://github.com/n8n-io/n8n/issues/11210)) ([4aaebfd](https://github.com/n8n-io/n8n/commit/4aaebfd4358f590e98c453ad4e65cc2c9d0f76f8))
* **editor:** Make submit in ChangePasswordView work again ([#11227](https://github.com/n8n-io/n8n/issues/11227)) ([4f27b39](https://github.com/n8n-io/n8n/commit/4f27b39b45b58779d363980241e6e5e11b58f5da))
* Expressions display actual result of evaluating expression inside string ([#11257](https://github.com/n8n-io/n8n/issues/11257)) ([7f5f0a9](https://github.com/n8n-io/n8n/commit/7f5f0a9df3b3fae6e2f9787443ac1cf9415d5932))
* **Google Ads Node:** Update to use v17 api ([#11243](https://github.com/n8n-io/n8n/issues/11243)) ([3d97f02](https://github.com/n8n-io/n8n/commit/3d97f02a8d2b6e5bc7c97c5271bed97417ecacd2))
* **Google Calendar Node:** Fix issue with conference data types not loading ([#11185](https://github.com/n8n-io/n8n/issues/11185)) ([4012758](https://github.com/n8n-io/n8n/commit/401275884e5db0287e4eeffb3c7497dd5e024880))
* **Google Calendar Node:** Mode to add or replace attendees in event update ([#11132](https://github.com/n8n-io/n8n/issues/11132)) ([6c6a8ef](https://github.com/n8n-io/n8n/commit/6c6a8efdea83cf7194304ce089d7b72d8f6c1a9d))
* **HTTP Request Tool Node:** Respond with an error when receive binary response ([#11219](https://github.com/n8n-io/n8n/issues/11219)) ([0d23a7f](https://github.com/n8n-io/n8n/commit/0d23a7fb5ba41545f70c4848d30b90af91b1e7e6))
* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#11242](https://github.com/n8n-io/n8n/issues/11242)) ([b7ee0c4](https://github.com/n8n-io/n8n/commit/b7ee0c4087eae346bc7e5360130d6c812dbe99db))
* **n8n Trigger Node:** Merge with Workflow Trigger node ([#11174](https://github.com/n8n-io/n8n/issues/11174)) ([6ec6b51](https://github.com/n8n-io/n8n/commit/6ec6b5197ae97eb86496effd458fcc0b9b223ef3))
* **OpenAI Node:** Fix tool parameter parsing issue ([#11201](https://github.com/n8n-io/n8n/issues/11201)) ([5a1d81a](https://github.com/n8n-io/n8n/commit/5a1d81ad917fde5cd6a387fe2d4ec6aab6b71349))
* **Set Node:** Fix issue with UI properties not being hidden ([#11263](https://github.com/n8n-io/n8n/issues/11263)) ([1affc27](https://github.com/n8n-io/n8n/commit/1affc27b6bf9a559061a06f92bebe8167d938665))
* **Strava Trigger Node:** Fix issue with webhook not being deleted ([#11226](https://github.com/n8n-io/n8n/issues/11226)) ([566529c](https://github.com/n8n-io/n8n/commit/566529ca1149988a54a58b3c34bbe4d9f1add6db))
### Features
* Add tracking for node errors and update node graph ([#11060](https://github.com/n8n-io/n8n/issues/11060)) ([d3b05f1](https://github.com/n8n-io/n8n/commit/d3b05f1c54e62440666297d8e484ccd22168da48))
* **core:** Dedupe ([#10101](https://github.com/n8n-io/n8n/issues/10101)) ([52dd2c7](https://github.com/n8n-io/n8n/commit/52dd2c76196c6895b47145c2b85a6895ce2874d4))
* **editor:** Send workflow context to assistant store ([#11135](https://github.com/n8n-io/n8n/issues/11135)) ([fade9e4](https://github.com/n8n-io/n8n/commit/fade9e43c84a0ae1fbc80f3ee546a418970e2380))
* **Gong Node:** New node ([#10777](https://github.com/n8n-io/n8n/issues/10777)) ([785b47f](https://github.com/n8n-io/n8n/commit/785b47feb3b83cf36aaed57123f8baca2bbab307))
### Performance Improvements
* **Google Sheets Node:** Don't load whole spreadsheet dataset to determine columns when appending data ([#11235](https://github.com/n8n-io/n8n/issues/11235)) ([26ad091](https://github.com/n8n-io/n8n/commit/26ad091f473bca4e5d3bdc257e0818be02e52db5))
# [1.63.0](https://github.com/n8n-io/n8n/compare/n8n@1.62.1...n8n@1.63.0) (2024-10-09)

View file

@ -20,6 +20,7 @@ describe('Undo/Redo', () => {
WorkflowPage.actions.visit();
});
// FIXME: Canvas V2: Fix redo connections
it('should undo/redo adding node in the middle', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -114,6 +115,7 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
// FIXME: Canvas V2: Fix moving of nodes via e2e tests
it('should undo/redo moving nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -146,18 +148,14 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting a connection using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().realHover();
cy.get('.connection-actions .delete')
.filter(':visible')
.should('be.visible')
.click({ force: true });
WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
// FIXME: Canvas V2: Fix disconnecting by moving
it('should undo/redo deleting a connection by moving it away', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -206,6 +204,7 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.disabledNodes().should('have.length', 2);
});
// FIXME: Canvas V2: Fix undo renaming node
it('should undo/redo renaming node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -244,6 +243,7 @@ describe('Undo/Redo', () => {
});
});
// FIXME: Canvas V2: Figure out why moving doesn't work from e2e
it('should undo/redo multiple steps', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);

View file

@ -16,6 +16,7 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.visit();
});
// FIXME: Canvas V2: Missing execute button if no nodes
it('should render canvas', () => {
WorkflowPage.getters.nodeViewRoot().should('be.visible');
WorkflowPage.getters.canvasPlusButton().should('be.visible');
@ -25,10 +26,11 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
});
// FIXME: Canvas V2: Fix changing of connection
it('should connect and disconnect a simple node', () => {
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
cy.get('.jtk-connector').should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.getters.nodeViewBackground().click(600, 400, { force: true });
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
@ -40,16 +42,16 @@ describe('Canvas Actions', () => {
);
WorkflowPage.getters
.canvasNodeInputEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}1`)
.should('have.class', 'jtk-endpoint-connected');
.getConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, `${EDIT_FIELDS_SET_NODE_NAME}1`)
.should('be.visible');
cy.get('.jtk-connector').should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
// Disconnect Set1
cy.drag(
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
[-200, 100],
);
cy.get('.jtk-connector').should('have.length', 0);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should add first step', () => {
@ -74,7 +76,7 @@ describe('Canvas Actions', () => {
it('should add a connected node using plus endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}');
@ -85,7 +87,7 @@ describe('Canvas Actions', () => {
it('should add a connected node dragging from node creator', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.nodeCreatorNodeItems().first(), [100, 100], {
@ -99,7 +101,7 @@ describe('Canvas Actions', () => {
it('should open a category when trying to drag and drop it on the canvas', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.nodeCreatorActionItems().first(), [100, 100], {
@ -114,7 +116,7 @@ describe('Canvas Actions', () => {
it('should add disconnected node if nothing is selected', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
// Deselect nodes
WorkflowPage.getters.nodeViewBackground().click({ force: true });
WorkflowPage.getters.nodeView().click({ force: true });
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
@ -136,10 +138,10 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 3);
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).then(($editFieldsNode) => {
const editFieldsNodeLeft = parseFloat($editFieldsNode.css('left'));
const editFieldsNodeLeft = WorkflowPage.getters.getNodeLeftPosition($editFieldsNode);
WorkflowPage.getters.canvasNodeByName(HTTP_REQUEST_NODE_NAME).then(($httpNode) => {
const httpNodeLeft = parseFloat($httpNode.css('left'));
const httpNodeLeft = WorkflowPage.getters.getNodeLeftPosition($httpNode);
expect(httpNodeLeft).to.be.lessThan(editFieldsNodeLeft);
});
});
@ -159,10 +161,12 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().realHover();
cy.get('.connection-actions .delete').first().click({ force: true });
WorkflowPage.actions.deleteNodeBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
// FIXME: Canvas V2: Fix disconnecting of connection by dragging it
it('should delete a connection by moving it away from endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
@ -216,10 +220,10 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.hitCopy();
successToast().should('contain', 'Copied!');
successToast().should('contain', 'Copied to clipboard');
WorkflowPage.actions.copyNode(CODE_NODE_NAME);
successToast().should('contain', 'Copied!');
successToast().should('contain', 'Copied to clipboard');
});
it('should select/deselect all nodes', () => {
@ -231,17 +235,31 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.selectedNodes().should('have.length', 0);
});
// FIXME: Canvas V2: Selection via arrow keys is broken
it('should select nodes using arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
cy.get('body').type('{leftArrow}');
WorkflowPage.getters.canvasNodes().first().should('have.class', 'jtk-drag-selected');
const selectedCanvasNodes = () =>
cy.ifCanvasVersion(
() => WorkflowPage.getters.canvasNodes(),
() => WorkflowPage.getters.canvasNodes().parent(),
);
cy.ifCanvasVersion(
() => selectedCanvasNodes().first().should('have.class', 'jtk-drag-selected'),
() => selectedCanvasNodes().first().should('have.class', 'selected'),
);
cy.get('body').type('{rightArrow}');
WorkflowPage.getters.canvasNodes().last().should('have.class', 'jtk-drag-selected');
cy.ifCanvasVersion(
() => selectedCanvasNodes().last().should('have.class', 'jtk-drag-selected'),
() => selectedCanvasNodes().last().should('have.class', 'selected'),
);
});
// FIXME: Canvas V2: Selection via shift and arrow keys is broken
it('should select nodes using shift and arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
@ -251,6 +269,7 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.selectedNodes().should('have.length', 2);
});
// FIXME: Canvas V2: Fix select & deselect
it('should not break lasso selection when dragging node action buttons', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters
@ -262,6 +281,7 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
});
// FIXME: Canvas V2: Fix select & deselect
it('should not break lasso selection with multiple clicks on node action buttons', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);

View file

@ -9,6 +9,7 @@ import {
} from './../constants';
import { NDV, WorkflowExecutionsTab } from '../pages';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { isCanvasV2 } from '../utils/workflowUtils';
const WorkflowPage = new WorkflowPageClass();
const ExecutionsTab = new WorkflowExecutionsTab();
@ -52,15 +53,15 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.reload();
cy.waitForLoad();
// Make sure outputless switch was connected correctly
cy.get(
`[data-target-node="${SWITCH_NODE_NAME}1"][data-source-node="${EDIT_FIELDS_SET_NODE_NAME}3"]`,
).should('be.visible');
WorkflowPage.getters
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`)
.should('exist');
// Make sure all connections are there after reload
for (let i = 0; i < desiredOutputs; i++) {
const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
WorkflowPage.getters
.canvasNodeInputEndpointByName(setName)
.should('have.class', 'jtk-endpoint-connected');
.getConnectionBetweenNodes(`${SWITCH_NODE_NAME}`, setName)
.should('exist');
}
});
@ -69,9 +70,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 2; i++) {
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
WorkflowPage.getters
.nodeViewBackground()
.click((i + 1) * 200, (i + 1) * 200, { force: true });
WorkflowPage.getters.nodeView().click((i + 1) * 200, (i + 1) * 200, { force: true });
}
WorkflowPage.actions.zoomToFit();
@ -84,8 +83,6 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 2);
// Connect Set1 and Set2 to merge
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
@ -95,20 +92,36 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
const checkConnections = () => {
WorkflowPage.getters
.getConnectionBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
`${EDIT_FIELDS_SET_NODE_NAME}1`,
)
.should('exist');
WorkflowPage.getters
.getConnectionBetweenNodes(EDIT_FIELDS_SET_NODE_NAME, MERGE_NODE_NAME)
.should('exist');
WorkflowPage.getters
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}1`, MERGE_NODE_NAME)
.should('exist');
};
checkConnections();
// Make sure all connections are there after save & reload
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
checkConnections();
// cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
WorkflowPage.actions.executeWorkflow();
WorkflowPage.getters.stopExecutionButton().should('not.exist');
// If the merged set nodes are connected and executed correctly, there should be 2 items in the output of merge node
cy.get('[data-label="2 items"]').should('be.visible');
cy.ifCanvasVersion(
() => cy.get('[data-label="2 items"]').should('be.visible'),
() => cy.getByTestId('canvas-node-output-handle').contains('2 items').should('be.visible'),
);
});
it('should add nodes and check execution success', () => {
@ -120,16 +133,42 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.executeWorkflow();
cy.get('.jtk-connector.success').should('have.length', 3);
cy.get('.data-count').should('have.length', 4);
cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success');
cy.ifCanvasVersion(
() => cy.get('.jtk-connector.success').should('have.length', 3),
() => cy.get('[data-edge-status=success]').should('have.length', 3),
);
cy.ifCanvasVersion(
() => cy.get('.data-count').should('have.length', 4),
() => cy.getByTestId('canvas-node-status-success').should('have.length', 4),
);
cy.ifCanvasVersion(
() => cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success'),
() => cy.getByTestId('canvas-handle-plus').should('have.attr', 'data-plus-type', 'success'),
);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
cy.get('.plus-draggable-endpoint').filter(':visible').should('not.have.class', 'ep-success');
cy.get('.jtk-connector.success').should('have.length', 3);
cy.get('.jtk-connector').should('have.length', 4);
cy.ifCanvasVersion(
() =>
cy
.get('.plus-draggable-endpoint')
.filter(':visible')
.should('not.have.class', 'ep-success'),
() =>
cy.getByTestId('canvas-handle-plus').should('not.have.attr', 'data-plus-type', 'success'),
);
cy.ifCanvasVersion(
() => cy.get('.jtk-connector.success').should('have.length', 3),
// The new version of the canvas correctly shows executed data being passed to the input of the next node
() => cy.get('[data-edge-status=success]').should('have.length', 4),
);
cy.ifCanvasVersion(
() => cy.get('.data-count').should('have.length', 4),
() => cy.getByTestId('canvas-node-status-success').should('have.length', 4),
);
});
it('should delete node using context menu', () => {
@ -194,19 +233,29 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.canvasNodes().should('have.length', 0);
});
// FIXME: Canvas V2: Figure out how to test moving of the node
it('should move node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters
.canvasNodes()
.last()
.then(($node) => {
const { left, top } = $node.position();
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
clickToFinish: true,
});
if (isCanvasV2()) {
cy.drag('.vue-flow__node', [300, 300], {
realMouse: true,
});
} else {
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
clickToFinish: true,
});
}
WorkflowPage.getters
.canvasNodes()
.last()
@ -218,91 +267,80 @@ describe('Canvas Node Manipulation and Navigation', () => {
});
});
it('should zoom in', () => {
WorkflowPage.getters.zoomInButton().should('be.visible').click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_IN_X1_FACTOR}, 0, 0, ${ZOOM_IN_X1_FACTOR}, 0, 0)`,
describe('Canvas Zoom Functionality', () => {
const getContainer = () =>
cy.ifCanvasVersion(
() => WorkflowPage.getters.nodeView(),
() => WorkflowPage.getters.canvasViewport(),
);
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_IN_X2_FACTOR}, 0, 0, ${ZOOM_IN_X2_FACTOR}, 0, 0)`,
);
});
const checkZoomLevel = (expectedFactor: number) => {
return getContainer().should(($nodeView) => {
const newTransform = $nodeView.css('transform');
const newScale = parseFloat(newTransform.split(',')[0].slice(7));
it('should zoom out', () => {
WorkflowPage.getters.zoomOutButton().should('be.visible').click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_OUT_X1_FACTOR}, 0, 0, ${ZOOM_OUT_X1_FACTOR}, 0, 0)`,
);
WorkflowPage.getters.zoomOutButton().click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_OUT_X2_FACTOR}, 0, 0, ${ZOOM_OUT_X2_FACTOR}, 0, 0)`,
);
});
expect(newScale).to.be.closeTo(expectedFactor, 0.2);
});
};
it('should zoom using scroll or pinch gesture', () => {
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${PINCH_ZOOM_IN_FACTOR}, 0, 0, ${PINCH_ZOOM_IN_FACTOR}, 0, 0)`,
const zoomAndCheck = (action: 'zoomIn' | 'zoomOut', expectedFactor: number) => {
WorkflowPage.getters[`${action}Button`]().click();
checkZoomLevel(expectedFactor);
};
it('should zoom in', () => {
WorkflowPage.getters.zoomInButton().should('be.visible');
getContainer().then(($nodeView) => {
const initialTransform = $nodeView.css('transform');
const initialScale =
initialTransform === 'none' ? 1 : parseFloat(initialTransform.split(',')[0].slice(7));
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X1_FACTOR);
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X2_FACTOR);
});
});
it('should zoom out', () => {
zoomAndCheck('zoomOut', ZOOM_OUT_X1_FACTOR);
zoomAndCheck('zoomOut', ZOOM_OUT_X2_FACTOR);
});
it('should zoom using scroll or pinch gesture', () => {
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
// V2 Canvas is using the same zoom factor for both pinch and scroll
cy.ifCanvasVersion(
() => checkZoomLevel(PINCH_ZOOM_IN_FACTOR),
() => checkZoomLevel(ZOOM_IN_X1_FACTOR),
);
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
// Zoom in 1x + Zoom out 1x should reset to default (=1)
WorkflowPage.getters.nodeView().should('have.css', 'transform', 'matrix(1, 0, 0, 1, 0, 0)');
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
checkZoomLevel(1); // Zoom in 1x + Zoom out 1x should reset to default (=1)
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${PINCH_ZOOM_OUT_FACTOR}, 0, 0, ${PINCH_ZOOM_OUT_FACTOR}, 0, 0)`,
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
cy.ifCanvasVersion(
() => checkZoomLevel(PINCH_ZOOM_OUT_FACTOR),
() => checkZoomLevel(ZOOM_OUT_X1_FACTOR),
);
});
});
it('should reset zoom', () => {
// Reset zoom should not appear until zoom level changed
WorkflowPage.getters.resetZoomButton().should('not.exist');
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.resetZoomButton().should('be.visible').click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${DEFAULT_ZOOM_FACTOR}, 0, 0, ${DEFAULT_ZOOM_FACTOR}, 0, 0)`,
);
});
it('should reset zoom', () => {
WorkflowPage.getters.resetZoomButton().should('not.exist');
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.resetZoomButton().should('be.visible').click();
checkZoomLevel(DEFAULT_ZOOM_FACTOR);
});
it('should zoom to fit', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
// At this point last added node should be off-screen
WorkflowPage.getters.canvasNodes().last().should('not.be.visible');
WorkflowPage.getters.zoomToFitButton().click();
WorkflowPage.getters.canvasNodes().last().should('be.visible');
it('should zoom to fit', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
// At this point last added node should be off-screen
WorkflowPage.getters.canvasNodes().last().should('not.be.visible');
WorkflowPage.getters.zoomToFitButton().click();
WorkflowPage.getters.canvasNodes().last().should('be.visible');
});
});
it('should disable node (context menu or shortcut)', () => {
@ -426,9 +464,9 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.reload();
cy.waitForLoad();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
// FIXME: Canvas V2: Credentials should show issue on the first open
it('should remove unknown credentials on pasting workflow', () => {
cy.fixture('workflow-with-unknown-credentials.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
@ -441,6 +479,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
});
});
// FIXME: Canvas V2: Unknown nodes should still render connection endpoints
it('should render connections correctly if unkown nodes are present', () => {
const unknownNodeName = 'Unknown node';
cy.createFixtureWorkflow('workflow-with-unknown-nodes.json', 'Unknown nodes');

View file

@ -16,12 +16,14 @@ describe('n8n Form Trigger', () => {
ndv.getters.parameterInput('formDescription').type('Test Form Description');
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
ndv.getters.backToCanvas().click();
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist');
});
it('should fill up form fields', () => {
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger');
workflowPage.getters.canvasNodes().first().dblclick();
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger', {
isTrigger: true,
action: 'On new n8n Form event',
});
ndv.getters.parameterInput('formTitle').type('Test Form');
ndv.getters.parameterInput('formDescription').type('Test Form Description');
//fill up first field of type number
@ -96,6 +98,6 @@ describe('n8n Form Trigger', () => {
.type('Your test form was successfully submitted');
ndv.getters.backToCanvas().click();
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist');
});
});

View file

@ -1,6 +1,7 @@
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
import { clearNotifications, errorToast, successToast } from '../pages/notifications';
import { isCanvasV2 } from '../utils/workflowUtils';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
@ -117,15 +118,22 @@ describe('Execution', () => {
.canvasNodeByName('Manual')
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
if (isCanvasV2()) {
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.exist'));
} else {
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.be.visible'));
}
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
successToast().should('be.visible');
clearNotifications();
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
@ -206,6 +214,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('not.exist');
});
// FIXME: Canvas V2: Webhook should show waiting state but it doesn't
it('should test webhook workflow stop', () => {
cy.createFixtureWorkflow('Webhook_wait_set.json');
@ -267,9 +276,17 @@ describe('Execution', () => {
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
if (isCanvasV2()) {
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.exist'));
} else {
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.be.visible'));
}
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
@ -295,6 +312,7 @@ describe('Execution', () => {
});
});
// FIXME: Canvas V2: Missing pinned states for `edge-label-wrapper`
describe('connections should be colored differently for pinned data', () => {
beforeEach(() => {
cy.createFixtureWorkflow('Schedule_pinned.json');

View file

@ -0,0 +1,86 @@
import { NDV, WorkflowPage } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2362 ADO-2350 NDV Prevent clipping long parameters and scrolling to expression', () => {
it('should show last parameters and open at scroll top of parameters', () => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test-workflow-with-long-parameters.json');
workflowPage.actions.openNode('Schedule Trigger');
ndv.getters.inlineExpressionEditorInput().should('be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Edit Fields1');
// first parameter should be visible
ndv.getters.inputLabel().eq(0).should('include.text', 'Mode');
ndv.getters.inputLabel().eq(0).should('be.visible');
ndv.getters.inlineExpressionEditorInput().should('have.length', 2);
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Schedule Trigger');
// first parameter (notice) should be visible
ndv.getters.nthParam(0).should('include.text', 'This workflow will run on the schedule ');
ndv.getters.inputLabel().eq(0).should('be.visible');
ndv.getters.inlineExpressionEditorInput().should('have.length', 2);
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Slack');
// first field (credentials) should be visible
ndv.getters.nodeCredentialsLabel().should('be.visible');
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
});
it('NODE-1272 ensure expressions scrolled to top, not middle', () => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test-workflow-with-long-parameters.json');
workflowPage.actions.openNode('With long expression');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// should be scrolled at top
ndv.getters
.inlineExpressionEditorInput()
.eq(0)
.find('.cm-line')
.eq(0)
.should('have.text', '1 visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).find('.cm-line').eq(0).should('be.visible');
ndv.getters
.inlineExpressionEditorInput()
.eq(0)
.find('.cm-line')
.eq(6)
.should('have.text', '7 not visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).find('.cm-line').eq(6).should('not.be.visible');
});
});

View file

@ -117,7 +117,8 @@ describe('Debug', () => {
workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty');
workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.pinDataButton().click();
ndv.actions.unPinData();
ndv.actions.close();
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();

View file

@ -23,6 +23,7 @@ describe('Manual partial execution', () => {
canvas.actions.openNode('Webhook1');
ndv.getters.nodeRunSuccessIndicator().should('exist');
ndv.getters.nodeRunTooltipIndicator().should('exist');
ndv.getters.outputRunSelector().should('not.exist'); // single run
});
});

View file

@ -133,9 +133,10 @@ describe('NDV', () => {
"An expression here won't work because it uses .item and n8n can't figure out the matching item.",
);
ndv.getters.nodeRunErrorIndicator().should('be.visible');
ndv.getters.nodeRunTooltipIndicator().should('be.visible');
// The error details should be hidden behind a tooltip
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Start Time');
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Execution Time');
ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Start Time');
ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Execution Time');
});
it('should save workflow using keyboard shortcut from NDV', () => {
@ -617,8 +618,10 @@ describe('NDV', () => {
// Should not show run info before execution
ndv.getters.nodeRunSuccessIndicator().should('not.exist');
ndv.getters.nodeRunErrorIndicator().should('not.exist');
ndv.getters.nodeRunTooltipIndicator().should('not.exist');
ndv.getters.nodeExecuteButton().click();
ndv.getters.nodeRunSuccessIndicator().should('exist');
ndv.getters.nodeRunTooltipIndicator().should('exist');
});
it('should properly show node execution indicator for multiple nodes', () => {
@ -630,6 +633,7 @@ describe('NDV', () => {
// Manual tigger node should show success indicator
workflowPage.actions.openNode('When clicking Test workflow');
ndv.getters.nodeRunSuccessIndicator().should('exist');
ndv.getters.nodeRunTooltipIndicator().should('exist');
// Code node should show error
ndv.getters.backToCanvas().click();
workflowPage.actions.openNode('Code');

View file

@ -162,21 +162,21 @@ return []
cy.get('#tab-code').should('have.class', 'is-active');
});
it('should show error based on status code', () => {
const prompt = nanoid(20);
cy.get('#tab-ask-ai').click();
ndv.actions.executePrevious();
const handledCodes = [
{ code: 400, message: 'Code generation failed due to an unknown reason' },
{ code: 413, message: 'Your workflow data is too large for AI to process' },
{ code: 429, message: "We've hit our rate limit with our AI partner" },
{ code: 500, message: 'Code generation failed due to an unknown reason' },
];
cy.getByTestId('ask-ai-prompt-input').type(prompt);
handledCodes.forEach(({ code, message }) => {
it(`should show error based on status code ${code}`, () => {
const prompt = nanoid(20);
cy.get('#tab-ask-ai').click();
ndv.actions.executePrevious();
const handledCodes = [
{ code: 400, message: 'Code generation failed due to an unknown reason' },
{ code: 413, message: 'Your workflow data is too large for AI to process' },
{ code: 429, message: "We've hit our rate limit with our AI partner" },
{ code: 500, message: 'Code generation failed due to an unknown reason' },
];
cy.getByTestId('ask-ai-prompt-input').type(prompt);
handledCodes.forEach(({ code, message }) => {
cy.intercept('POST', '/rest/ai/ask-ai', {
statusCode: code,
status: code,

View file

@ -0,0 +1,150 @@
{
"meta": {
"instanceId": "777c68374367604fdf2a0bcfe9b1b574575ddea61aa8268e4bf034434bd7c894"
},
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "0effebfc-fa8c-4d41-8a37-6d5695dfc9ee",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "beb8723f-6333-4186-ab88-41d4e2338866",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "85095836-4e94-442f-9270-e1a89008c129",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "b6163f8a-bca6-4364-8b38-182df37c55cd",
"name": "=should be visible!",
"value": "=not visible",
"type": "string"
}
]
},
"options": {}
},
"id": "950fcdc1-9e92-410f-8377-d4240e9bf6ff",
"name": "Edit Fields1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
680,
460
]
},
{
"parameters": {
"messageType": "block",
"blocksUi": "blocks",
"text": "=should be visible",
"otherOptions": {
"sendAsUser": "=not visible"
}
},
"id": "dcf7410d-0f8e-4cdb-9819-ae275558bdaa",
"name": "Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
900,
460
],
"webhookId": "002b502e-31e5-4fdb-ac43-a56cfde8f82a"
},
{
"parameters": {
"rule": {
"interval": [
{},
{
"field": "=should be visible"
},
{
"field": "=not visible"
}
]
}
},
"id": "4c948a3f-19d4-4b08-a8be-f7d2964a21f4",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
460,
460
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "5dcaab37-1146-49c6-97a3-3b2f73483270",
"name": "object",
"value": "=1 visible!\n2 {\n3 \"str\": \"two\",\n4 \"str_date\": \"{{ $now }}\",\n5 \"str_int\": \"1\",\n6 \"str_float\": \"1.234\",\n7 not visible!\n \"str_bool\": \"true\",\n \"str_email\": \"david@thedavid.com\",\n \"str_with_email\":\"My email is david@n8n.io\",\n \"str_json_single\":\"{'one':'two'}\",\n \"str_json_double\":\"{\\\"one\\\":\\\"two\\\"}\",\n \"bool\": true,\n \"list\": [1, 2, 3],\n \"decimal\": 1.234,\n \"timestamp1\": 1708695471,\n \"timestamp2\": 1708695471000,\n \"timestamp3\": 1708695471000000,\n \"num_one\": 1\n}",
"type": "object"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "a41dfb0d-38aa-42d2-b3e2-1854090bd319",
"name": "With long expression",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1100,
460
]
}
],
"connections": {
"Edit Fields1": {
"main": [
[
{
"node": "Slack",
"type": "main",
"index": 0
}
]
]
},
"Slack": {
"main": [
[
{
"node": "With long expression",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Edit Fields1",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}

View file

@ -20,7 +20,8 @@ export class NDV extends BasePage {
outputDataContainer: () => this.getters.outputPanel().findChildByTestId('ndv-data-container'),
outputDisplayMode: () =>
this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
pinDataButton: () => cy.getByTestId('ndv-pin-data'),
pinDataButton: () => this.getters.outputPanel().findChildByTestId('ndv-pin-data'),
unpinDataLink: () => this.getters.outputPanel().findChildByTestId('ndv-unpin-data'),
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller .cm-content'),
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
@ -63,6 +64,7 @@ export class NDV extends BasePage {
nodeRenameInput: () => cy.getByTestId('node-rename-input'),
executePrevious: () => cy.getByTestId('execute-previous-node'),
httpRequestNotice: () => cy.getByTestId('node-parameters-http-notice'),
nodeCredentialsLabel: () => cy.getByTestId('credentials-label'),
nthParam: (n: number) => cy.getByTestId('node-parameters').find('.parameter-item').eq(n),
inputRunSelector: () => this.getters.inputPanel().findChildByTestId('run-selector'),
inputLinkRun: () => this.getters.inputPanel().findChildByTestId('link-run'),
@ -130,8 +132,9 @@ export class NDV extends BasePage {
codeEditorFullscreenButton: () => cy.getByTestId('code-editor-fullscreen-button'),
codeEditorDialog: () => cy.getByTestId('code-editor-fullscreen'),
codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'),
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'),
nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'),
nodeRunTooltipIndicator: () => cy.getByTestId('node-run-info'),
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-status-success'),
nodeRunErrorIndicator: () => cy.getByTestId('node-run-status-danger'),
nodeRunErrorMessage: () => cy.getByTestId('node-error-message'),
nodeRunErrorDescription: () => cy.getByTestId('node-error-description'),
fixedCollectionParameter: (paramName: string) =>
@ -146,6 +149,9 @@ export class NDV extends BasePage {
pinData: () => {
this.getters.pinDataButton().click({ force: true });
},
unPinData: () => {
this.getters.unpinDataLink().click({ force: true });
},
editPinnedData: () => {
this.getters.editPinnedDataButton().click();
},

View file

@ -2,7 +2,7 @@ import { BasePage } from './base';
import { NodeCreator } from './features/node-creator';
import { META_KEY } from '../constants';
import { getVisibleSelect } from '../utils';
import { getUniqueWorkflowName } from '../utils/workflowUtils';
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
const nodeCreator = new NodeCreator();
export class WorkflowPage extends BasePage {
@ -27,7 +27,11 @@ export class WorkflowPage extends BasePage {
nodeCreatorSearchBar: () => cy.getByTestId('node-creator-search-bar'),
nodeCreatorPlusButton: () => cy.getByTestId('node-creator-plus-button'),
canvasPlusButton: () => cy.getByTestId('canvas-plus-button'),
canvasNodes: () => cy.getByTestId('canvas-node'),
canvasNodes: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node'),
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
),
canvasNodeByName: (nodeName: string) =>
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
nodeIssuesByName: (nodeName: string) =>
@ -37,6 +41,17 @@ export class WorkflowPage extends BasePage {
.should('have.length.greaterThan', 0)
.findChildByTestId('node-issues'),
getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => {
if (isCanvasV2()) {
if (type === 'input') {
return `[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"]`;
}
if (type === 'output') {
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"]`;
}
if (type === 'plus') {
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"] [data-test-id="canvas-handle-plus"] .clickable`;
}
}
return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`;
},
canvasNodeInputEndpointByName: (nodeName: string, index = 0) => {
@ -46,7 +61,15 @@ export class WorkflowPage extends BasePage {
return cy.get(this.getters.getEndpointSelector('output', nodeName, index));
},
canvasNodePlusEndpointByName: (nodeName: string, index = 0) => {
return cy.get(this.getters.getEndpointSelector('plus', nodeName, index));
return cy.ifCanvasVersion(
() => cy.get(this.getters.getEndpointSelector('plus', nodeName, index)),
() =>
cy
.get(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"] .clickable`,
)
.eq(index),
);
},
activatorSwitch: () => cy.getByTestId('workflow-activate-switch'),
workflowMenu: () => cy.getByTestId('workflow-menu'),
@ -56,13 +79,29 @@ export class WorkflowPage extends BasePage {
expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'),
expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
nodeViewRoot: () => cy.getByTestId('node-view-root'),
nodeViewRoot: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view-root'),
() => this.getters.nodeView(),
),
copyPasteInput: () => cy.getByTestId('hidden-copy-paste'),
nodeConnections: () => cy.get('.jtk-connector'),
nodeConnections: () =>
cy.ifCanvasVersion(
() => cy.get('.jtk-connector'),
() => cy.getByTestId('edge-label-wrapper'),
),
zoomToFitButton: () => cy.getByTestId('zoom-to-fit'),
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
disabledNodes: () => cy.get('.node-box.disabled'),
selectedNodes: () => this.getters.canvasNodes().filter('.jtk-drag-selected'),
disabledNodes: () =>
cy.ifCanvasVersion(
() => cy.get('.node-box.disabled'),
() => cy.get('[data-test-id="canvas-trigger-node"][class*="disabled"]'),
),
selectedNodes: () =>
cy.ifCanvasVersion(
() => this.getters.canvasNodes().filter('.jtk-drag-selected'),
() => this.getters.canvasNodes().parent().filter('.selected'),
),
// Workflow menu items
workflowMenuItemDuplicate: () => cy.getByTestId('workflow-menu-item-duplicate'),
workflowMenuItemDownload: () => cy.getByTestId('workflow-menu-item-download'),
@ -92,8 +131,21 @@ export class WorkflowPage extends BasePage {
shareButton: () => cy.getByTestId('workflow-share-button'),
duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'),
nodeViewBackground: () => cy.getByTestId('node-view-background'),
nodeView: () => cy.getByTestId('node-view'),
nodeViewBackground: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view-background'),
() => cy.getByTestId('canvas'),
),
nodeView: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view'),
() => cy.get('[data-test-id="canvas-wrapper"]'),
),
canvasViewport: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view'),
() => cy.get('.vue-flow__transformationpane.vue-flow__container'),
),
inlineExpressionEditorInput: () =>
cy.getByTestId('inline-expression-editor-input').find('[role=textbox]'),
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'),
@ -115,12 +167,26 @@ export class WorkflowPage extends BasePage {
ndvParameters: () => cy.getByTestId('parameter-item'),
nodeCredentialsLabel: () => cy.getByTestId('credentials-label'),
getConnectionBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
cy.get(
`.jtk-connector[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
cy.ifCanvasVersion(
() =>
cy.get(
`.jtk-connector[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
),
),
getConnectionActionsBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
cy.get(
`.connection-actions[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
cy.ifCanvasVersion(
() =>
cy.get(
`.connection-actions[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
),
),
addStickyButton: () => cy.getByTestId('add-sticky-button'),
stickies: () => cy.getByTestId('sticky'),
@ -128,6 +194,18 @@ export class WorkflowPage extends BasePage {
workflowHistoryButton: () => cy.getByTestId('workflow-history-button'),
colors: () => cy.getByTestId('color'),
contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`),
getNodeLeftPosition: (element: JQuery<HTMLElement>) => {
if (isCanvasV2()) {
return parseFloat(element.parent().css('transform').split(',')[4]);
}
return parseFloat(element.css('left'));
},
getNodeTopPosition: (element: JQuery<HTMLElement>) => {
if (isCanvasV2()) {
return parseFloat(element.parent().css('transform').split(',')[5]);
}
return parseFloat(element.css('top'));
},
};
actions = {
@ -332,7 +410,7 @@ export class WorkflowPage extends BasePage {
pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => {
cy.window().then((win) => {
// Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling)
this.getters.nodeViewBackground().trigger('wheel', {
this.getters.nodeView().trigger('wheel', {
force: true,
bubbles: true,
ctrlKey: true,
@ -391,9 +469,12 @@ export class WorkflowPage extends BasePage {
action?: string,
) => {
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
this.getters
.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName)
.find('.add')
const connectionsBetweenNodes = () =>
this.getters.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName);
cy.ifCanvasVersion(
() => connectionsBetweenNodes().find('.add'),
() => connectionsBetweenNodes().get('[data-test-id="add-connection-button"]'),
)
.first()
.click({ force: true });
@ -401,9 +482,12 @@ export class WorkflowPage extends BasePage {
},
deleteNodeBetweenNodes: (sourceNodeName: string, targetNodeName: string) => {
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
this.getters
.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName)
.find('.delete')
const connectionsBetweenNodes = () =>
this.getters.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName);
cy.ifCanvasVersion(
() => connectionsBetweenNodes().find('.delete'),
() => connectionsBetweenNodes().get('[data-test-id="delete-connection-button"]'),
)
.first()
.click({ force: true });
},

View file

@ -10,7 +10,7 @@ import {
N8N_AUTH_COOKIE,
} from '../constants';
import { WorkflowPage } from '../pages';
import { getUniqueWorkflowName } from '../utils/workflowUtils';
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
Cypress.Commands.add('setAppDate', (targetDate: number | Date) => {
cy.window().then((win) => {
@ -26,6 +26,10 @@ Cypress.Commands.add('getByTestId', (selector, ...args) => {
return cy.get(`[data-test-id="${selector}"]`, ...args);
});
Cypress.Commands.add('ifCanvasVersion', (getterV1, getterV2) => {
return isCanvasV2() ? getterV2() : getterV1();
});
Cypress.Commands.add(
'createFixtureWorkflow',
(fixtureKey: string, workflowName = getUniqueWorkflowName()) => {
@ -70,6 +74,10 @@ Cypress.Commands.add('signin', ({ email, password }) => {
})
.then((response) => {
Cypress.env('currentUserId', response.body.data.id);
cy.window().then((win) => {
win.localStorage.setItem('NodeView.switcher.discovered', 'true'); // @TODO Remove this once the switcher is removed
});
});
});
});

View file

@ -20,6 +20,11 @@ beforeEach(() => {
win.localStorage.setItem('N8N_THEME', 'light');
win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true');
win.localStorage.setItem('N8N_MAPPING_ONBOARDED', 'true');
const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION');
if (nodeViewVersion) {
win.localStorage.setItem('NodeView.version', nodeViewVersion);
}
});
cy.intercept('GET', '/rest/settings', (req) => {

View file

@ -28,6 +28,7 @@ declare global {
selector: string,
...args: Array<Partial<Loggable & Timeoutable & Withinable & Shadow> | undefined>
): Chainable<JQuery<HTMLElement>>;
ifCanvasVersion<T1, T2>(getterV1: () => T1, getterV2: () => T2): T1 | T2;
findChildByTestId(childTestId: string): Chainable<JQuery<HTMLElement>>;
/**
* Creates a workflow from the given fixture and optionally renames it.

View file

@ -3,3 +3,7 @@ import { nanoid } from 'nanoid';
export function getUniqueWorkflowName(workflowNamePrefix?: string) {
return workflowNamePrefix ? `${workflowNamePrefix} ${nanoid(12)}` : nanoid(12);
}
export function isCanvasV2() {
return Cypress.env('NODE_VIEW_VERSION') === 2;
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.63.0",
"version": "1.64.0",
"private": true,
"engines": {
"node": ">=20.15",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.4.0",
"version": "0.5.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -15,6 +15,12 @@ export default function () {
const res = http.post(`${apiBaseUrl}/webhook/binary-files-benchmark`, data);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has correct content type': (r) =>

View file

@ -6,6 +6,12 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/benchmark-http-node`);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'http requests were OK': (r) => {

View file

@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/code-node-benchmark`, {});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has items in response': (r) => {

View file

@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/set-expressions-benchmark`, {});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
});

View file

@ -3,5 +3,5 @@
"name": "SingleWebhook",
"description": "A single webhook trigger that responds with a 200 status code",
"scenarioData": { "workflowFiles": ["single-webhook.json"] },
"scriptPath": "single-webhook.script.ts"
"scriptPath": "single-webhook.script.js"
}

View file

@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.get(`${apiBaseUrl}/webhook/single-webhook`);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
});

View file

@ -176,7 +176,7 @@ services:
# Load balancer that acts as an entry point for n8n
n8n:
image: nginx:latest
image: nginx:1.27.2
ports:
- '5678:80'
volumes:

View file

@ -3,6 +3,7 @@ events {}
http {
client_max_body_size 50M;
access_log off;
error_log /dev/stderr warn;
upstream backend {
server n8n_main1:5678;

View file

@ -78,12 +78,6 @@ async function runBenchmarksOnVm(config, benchmarkEnv) {
const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh');
await sshClient.ssh(`chmod a+x ${bootstrapScriptPath} && ${bootstrapScriptPath}`);
// Benchmarking the VM
const vmBenchmarkScriptPath = path.join(scriptsDir, 'vm-benchmark.sh');
await sshClient.ssh(`chmod a+x ${vmBenchmarkScriptPath} && ${vmBenchmarkScriptPath}`, {
verbose: true,
});
// Give some time for the VM to be ready
await sleep(1000);

View file

@ -1,13 +0,0 @@
#!/bin/bash
# Install fio
DEBIAN_FRONTEND=noninteractive sudo apt-get -y install fio > /dev/null
# Run the disk benchmark
fio --name=rand_rw --ioengine=libaio --rw=randrw --rwmixread=70 --bs=4k --numjobs=4 --size=1G --runtime=30 --directory=/n8n --group_reporting
# Remove files
sudo rm /n8n/rand_rw.*
# Uninstall fio
DEBIAN_FRONTEND=noninteractive sudo apt-get -y remove fio > /dev/null

View file

@ -1,3 +1,5 @@
import { sleep } from 'zx';
import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
import type { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
import type { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
@ -47,6 +49,10 @@ export class ScenarioRunner {
const testData = await this.dataLoader.loadDataForScenario(scenario);
await testDataImporter.importTestScenarioData(testData.workflows);
// Wait for 1s before executing the scenario to ensure that the workflows are activated.
// In multi-main mode it can take some time before the workflow becomes active.
await sleep(1000);
console.log('Executing scenario script');
await this.k6Executor.executeTestScenario(scenario, {
scenarioRunName,

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "0.28.0",
"version": "0.29.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm build:bundle",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.13.0",
"version": "1.14.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -0,0 +1,15 @@
import { Config, Env } from '../decorators';
@Config
export class GenericConfig {
/** Default timezone for the n8n instance. Can be overridden on a per-workflow basis. */
@Env('GENERIC_TIMEZONE')
timezone: string = 'America/New_York';
@Env('N8N_RELEASE_TYPE')
releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev' = 'dev';
/** Grace period (in seconds) to wait for components to shut down before process exit. */
@Env('N8N_GRACEFUL_SHUTDOWN_TIMEOUT')
gracefulShutdownTimeout: number = 30;
}

View file

@ -0,0 +1,16 @@
import { Config, Env } from '../decorators';
@Config
export class MultiMainSetupConfig {
/** Whether to enable multi-main setup (if licensed) for scaling mode. */
@Env('N8N_MULTI_MAIN_SETUP_ENABLED')
enabled: boolean = false;
/** Time to live (in seconds) for leader key in multi-main setup. */
@Env('N8N_MULTI_MAIN_SETUP_KEY_TTL')
ttl: number = 10;
/** Interval (in seconds) for leader check in multi-main setup. */
@Env('N8N_MULTI_MAIN_SETUP_CHECK_INTERVAL')
interval: number = 3;
}

View file

@ -82,10 +82,6 @@ class BullConfig {
@Nested
redis: RedisConfig;
/** How often (in seconds) to poll the Bull queue to identify executions finished during a Redis crash. `0` to disable. May increase Redis traffic significantly. */
@Env('QUEUE_RECOVERY_INTERVAL')
queueRecoveryInterval: number = 60; // watchdog interval
/** @deprecated How long (in seconds) a worker must wait for active executions to finish before exiting. Use `N8N_GRACEFUL_SHUTDOWN_TIMEOUT` instead */
@Env('QUEUE_WORKER_TIMEOUT')
gracefulShutdownTimeout: number = 30;

View file

@ -5,7 +5,9 @@ import { EndpointsConfig } from './configs/endpoints.config';
import { EventBusConfig } from './configs/event-bus.config';
import { ExternalSecretsConfig } from './configs/external-secrets.config';
import { ExternalStorageConfig } from './configs/external-storage.config';
import { GenericConfig } from './configs/generic.config';
import { LoggingConfig } from './configs/logging.config';
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
import { NodesConfig } from './configs/nodes.config';
import { PublicApiConfig } from './configs/public-api.config';
import { TaskRunnersConfig } from './configs/runners.config';
@ -93,4 +95,10 @@ export class GlobalConfig {
@Nested
taskRunners: TaskRunnersConfig;
@Nested
multiMainSetup: MultiMainSetupConfig;
@Nested
generic: GenericConfig;
}

View file

@ -211,7 +211,6 @@ describe('GlobalConfig', () => {
clusterNodes: '',
tls: false,
},
queueRecoveryInterval: 60,
gracefulShutdownTimeout: 30,
prefix: 'bull',
settings: {
@ -246,6 +245,16 @@ describe('GlobalConfig', () => {
},
scopes: [],
},
multiMainSetup: {
enabled: false,
ttl: 10,
interval: 3,
},
generic: {
timezone: 'America/New_York',
releaseChannel: 'dev',
gracefulShutdownTimeout: 30,
},
};
it('should use all default values when no env variables are defined', () => {

View file

@ -0,0 +1,21 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
...sharedOptions(__dirname),
ignorePatterns: ['jest.config.js'],
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'@typescript-eslint/no-duplicate-imports': 'off',
'import/no-cycle': 'off',
'n8n-local-rules/no-plain-errors': 'off',
complexity: 'error',
},
};

View file

@ -0,0 +1,4 @@
node_modules
dist
coverage
test/output

View file

@ -0,0 +1,3 @@
src
tsconfig*
test

View file

@ -0,0 +1,16 @@
ISC License
Copyright (c) 2024, n8n
Copyright (c) 2021, Stefan Terdell
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View file

@ -0,0 +1,34 @@
# Json-Schema-to-Zod
A package to convert JSON schema (draft 4+) objects into Zod schemas in the form of Zod objects at runtime.
## Installation
```sh
npm install @n8n/json-schema-to-zod
```
### Simple example
```typescript
import { jsonSchemaToZod } from "json-schema-to-zod";
const jsonSchema = {
type: "object",
properties: {
hello: {
type: "string",
},
},
};
const zodSchema = jsonSchemaToZod(myObject);
```
### Overriding a parser
You can pass a function to the `overrideParser` option, which represents a function that receives the current schema node and the reference object, and should return a zod object when it wants to replace a default output. If the default output should be used for the node just return undefined.
## Acknowledgements
This is a fork of [`json-schema-to-zod`](https://github.com/StefanTerdell/json-schema-to-zod).

View file

@ -0,0 +1,5 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
setupFilesAfterEnv: ['<rootDir>/test/extend-expect.ts'],
};

View file

@ -0,0 +1,69 @@
{
"name": "@n8n/json-schema-to-zod",
"version": "1.0.0",
"description": "Converts JSON schema objects into Zod schemas",
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"exports": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
}
},
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"format": "biome format --write src",
"format:check": "biome ci src",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"build:types": "tsc -p tsconfig.types.json",
"build:cjs": "tsc -p tsconfig.cjs.json && node postcjs.js",
"build:esm": "tsc -p tsconfig.esm.json && node postesm.js",
"build": "rimraf ./dist && pnpm run build:types && pnpm run build:cjs && pnpm run build:esm",
"dry": "pnpm run build && pnpm pub --dry-run",
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [
"zod",
"json",
"schema",
"converter",
"cli"
],
"author": "Stefan Terdell",
"contributors": [
"Chen (https://github.com/werifu)",
"Nuno Carduso (https://github.com/ncardoso-barracuda)",
"Lars Strojny (https://github.com/lstrojny)",
"Navtoj Chahal (https://github.com/navtoj)",
"Ben McCann (https://github.com/benmccann)",
"Dmitry Zakharov (https://github.com/DZakh)",
"Michel Turpin (https://github.com/grimly)",
"David Barratt (https://github.com/davidbarratt)",
"pevisscher (https://github.com/pevisscher)",
"Aidin Abedi (https://github.com/aidinabedi)",
"Brett Zamir (https://github.com/brettz9)",
"n8n (https://github.com/n8n-io)"
],
"license": "ISC",
"repository": {
"type": "git",
"url": "https://github.com/n8n-io/n8n"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"devDependencies": {
"@types/json-schema": "^7.0.15",
"@types/node": "^20.9.0",
"zod": "catalog:"
}
}

View file

@ -0,0 +1 @@
require('fs').writeFileSync('./dist/cjs/package.json', '{"type":"commonjs"}', 'utf-8');

View file

@ -0,0 +1 @@
require('fs').writeFileSync('./dist/esm/package.json', '{"type":"module"}', 'utf-8');

View file

@ -0,0 +1,2 @@
export type * from './types';
export { jsonSchemaToZod } from './json-schema-to-zod.js';

View file

@ -0,0 +1,15 @@
import type { z } from 'zod';
import { parseSchema } from './parsers/parse-schema';
import type { JsonSchemaToZodOptions, JsonSchema } from './types';
export const jsonSchemaToZod = <T extends z.ZodTypeAny = z.ZodTypeAny>(
schema: JsonSchema,
options: JsonSchemaToZodOptions = {},
): T => {
return parseSchema(schema, {
path: [],
seen: new Map(),
...options,
}) as T;
};

View file

@ -0,0 +1,46 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
import { half } from '../utils/half';
const originalIndex = Symbol('Original index');
const ensureOriginalIndex = (arr: JsonSchema[]) => {
const newArr = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (typeof item === 'boolean') {
newArr.push(item ? { [originalIndex]: i } : { [originalIndex]: i, not: {} });
} else if (originalIndex in item) {
return arr;
} else {
newArr.push({ ...item, [originalIndex]: i });
}
}
return newArr;
};
export function parseAllOf(
jsonSchema: JsonSchemaObject & { allOf: JsonSchema[] },
refs: Refs,
): z.ZodTypeAny {
if (jsonSchema.allOf.length === 0) {
return z.never();
}
if (jsonSchema.allOf.length === 1) {
const item = jsonSchema.allOf[0];
return parseSchema(item, {
...refs,
path: [...refs.path, 'allOf', (item as never)[originalIndex]],
});
}
const [left, right] = half(ensureOriginalIndex(jsonSchema.allOf));
return z.intersection(parseAllOf({ allOf: left }, refs), parseAllOf({ allOf: right }, refs));
}

View file

@ -0,0 +1,19 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseAnyOf = (jsonSchema: JsonSchemaObject & { anyOf: JsonSchema[] }, refs: Refs) => {
return jsonSchema.anyOf.length
? jsonSchema.anyOf.length === 1
? parseSchema(jsonSchema.anyOf[0], {
...refs,
path: [...refs.path, 'anyOf', 0],
})
: z.union(
jsonSchema.anyOf.map((schema, i) =>
parseSchema(schema, { ...refs, path: [...refs.path, 'anyOf', i] }),
) as [z.ZodTypeAny, z.ZodTypeAny],
)
: z.any();
};

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { extendSchemaWithMessage } from '../utils/extend-schema';
export const parseArray = (jsonSchema: JsonSchemaObject & { type: 'array' }, refs: Refs) => {
if (Array.isArray(jsonSchema.items)) {
return z.tuple(
jsonSchema.items.map((v, i) =>
parseSchema(v, { ...refs, path: [...refs.path, 'items', i] }),
) as [z.ZodTypeAny],
);
}
let zodSchema = !jsonSchema.items
? z.array(z.any())
: z.array(parseSchema(jsonSchema.items, { ...refs, path: [...refs.path, 'items'] }));
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minItems',
(zs, minItems, errorMessage) => zs.min(minItems, errorMessage),
);
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maxItems',
(zs, maxItems, errorMessage) => zs.max(maxItems, errorMessage),
);
return zodSchema;
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseBoolean = (_jsonSchema: JsonSchemaObject & { type: 'boolean' }) => {
return z.boolean();
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject, Serializable } from '../types';
export const parseConst = (jsonSchema: JsonSchemaObject & { const: Serializable }) => {
return z.literal(jsonSchema.const as z.Primitive);
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseDefault = (_jsonSchema: JsonSchemaObject) => {
return z.any();
};

View file

@ -0,0 +1,25 @@
import { z } from 'zod';
import type { JsonSchemaObject, Serializable } from '../types';
export const parseEnum = (jsonSchema: JsonSchemaObject & { enum: Serializable[] }) => {
if (jsonSchema.enum.length === 0) {
return z.never();
}
if (jsonSchema.enum.length === 1) {
// union does not work when there is only one element
return z.literal(jsonSchema.enum[0] as z.Primitive);
}
if (jsonSchema.enum.every((x) => typeof x === 'string')) {
return z.enum(jsonSchema.enum as [string]);
}
return z.union(
jsonSchema.enum.map((x) => z.literal(x as z.Primitive)) as unknown as [
z.ZodTypeAny,
z.ZodTypeAny,
],
);
};

View file

@ -0,0 +1,31 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseIfThenElse = (
jsonSchema: JsonSchemaObject & {
if: JsonSchema;
then: JsonSchema;
else: JsonSchema;
},
refs: Refs,
) => {
const $if = parseSchema(jsonSchema.if, { ...refs, path: [...refs.path, 'if'] });
const $then = parseSchema(jsonSchema.then, {
...refs,
path: [...refs.path, 'then'],
});
const $else = parseSchema(jsonSchema.else, {
...refs,
path: [...refs.path, 'else'],
});
return z.union([$then, $else]).superRefine((value, ctx) => {
const result = $if.safeParse(value).success ? $then.safeParse(value) : $else.safeParse(value);
if (!result.success) {
result.error.errors.forEach((error) => ctx.addIssue(error));
}
});
};

View file

@ -0,0 +1,16 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchema, JsonSchemaObject, Refs } from '../types';
export const parseMultipleType = (
jsonSchema: JsonSchemaObject & { type: string[] },
refs: Refs,
) => {
return z.union(
jsonSchema.type.map((type) => parseSchema({ ...jsonSchema, type } as JsonSchema, refs)) as [
z.ZodTypeAny,
z.ZodTypeAny,
],
);
};

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseNot = (jsonSchema: JsonSchemaObject & { not: JsonSchema }, refs: Refs) => {
return z.any().refine(
(value) =>
!parseSchema(jsonSchema.not, {
...refs,
path: [...refs.path, 'not'],
}).safeParse(value).success,
'Invalid input: Should NOT be valid against schema',
);
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseNull = (_jsonSchema: JsonSchemaObject & { type: 'null' }) => {
return z.null();
};

View file

@ -0,0 +1,10 @@
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { omit } from '../utils/omit';
/**
* For compatibility with open api 3.0 nullable
*/
export const parseNullable = (jsonSchema: JsonSchemaObject & { nullable: true }, refs: Refs) => {
return parseSchema(omit(jsonSchema, 'nullable'), refs, true).nullable();
};

View file

@ -0,0 +1,88 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
import { extendSchemaWithMessage } from '../utils/extend-schema';
export const parseNumber = (jsonSchema: JsonSchemaObject & { type: 'number' | 'integer' }) => {
let zodSchema = z.number();
let isInteger = false;
if (jsonSchema.type === 'integer') {
isInteger = true;
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'type', (zs, _, errorMsg) =>
zs.int(errorMsg),
);
} else if (jsonSchema.format === 'int64') {
isInteger = true;
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'format', (zs, _, errorMsg) =>
zs.int(errorMsg),
);
}
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'multipleOf',
(zs, multipleOf, errorMsg) => {
if (multipleOf === 1) {
if (isInteger) return zs;
return zs.int(errorMsg);
}
return zs.multipleOf(multipleOf, errorMsg);
},
);
if (typeof jsonSchema.minimum === 'number') {
if (jsonSchema.exclusiveMinimum === true) {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minimum',
(zs, minimum, errorMsg) => zs.gt(minimum, errorMsg),
);
} else {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minimum',
(zs, minimum, errorMsg) => zs.gte(minimum, errorMsg),
);
}
} else if (typeof jsonSchema.exclusiveMinimum === 'number') {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'exclusiveMinimum',
(zs, exclusiveMinimum, errorMsg) => zs.gt(exclusiveMinimum as number, errorMsg),
);
}
if (typeof jsonSchema.maximum === 'number') {
if (jsonSchema.exclusiveMaximum === true) {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maximum',
(zs, maximum, errorMsg) => zs.lt(maximum, errorMsg),
);
} else {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maximum',
(zs, maximum, errorMsg) => zs.lte(maximum, errorMsg),
);
}
} else if (typeof jsonSchema.exclusiveMaximum === 'number') {
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'exclusiveMaximum',
(zs, exclusiveMaximum, errorMsg) => zs.lt(exclusiveMaximum as number, errorMsg),
);
}
return zodSchema;
};

View file

@ -0,0 +1,219 @@
import * as z from 'zod';
import { parseAllOf } from './parse-all-of';
import { parseAnyOf } from './parse-any-of';
import { parseOneOf } from './parse-one-of';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { its } from '../utils/its';
function parseObjectProperties(objectSchema: JsonSchemaObject & { type: 'object' }, refs: Refs) {
if (!objectSchema.properties) {
return undefined;
}
const propertyKeys = Object.keys(objectSchema.properties);
if (propertyKeys.length === 0) {
return z.object({});
}
const properties: Record<string, z.ZodTypeAny> = {};
for (const key of propertyKeys) {
const propJsonSchema = objectSchema.properties[key];
const propZodSchema = parseSchema(propJsonSchema, {
...refs,
path: [...refs.path, 'properties', key],
});
const hasDefault = typeof propJsonSchema === 'object' && propJsonSchema.default !== undefined;
const required = Array.isArray(objectSchema.required)
? objectSchema.required.includes(key)
: typeof propJsonSchema === 'object' && propJsonSchema.required === true;
const isOptional = !hasDefault && !required;
properties[key] = isOptional ? propZodSchema.optional() : propZodSchema;
}
return z.object(properties);
}
export function parseObject(
objectSchema: JsonSchemaObject & { type: 'object' },
refs: Refs,
): z.ZodTypeAny {
const hasPatternProperties = Object.keys(objectSchema.patternProperties ?? {}).length > 0;
const propertiesSchema:
| z.ZodObject<Record<string, z.ZodTypeAny>, 'strip', z.ZodTypeAny>
| undefined = parseObjectProperties(objectSchema, refs);
let zodSchema: z.ZodTypeAny | undefined = propertiesSchema;
const additionalProperties =
objectSchema.additionalProperties !== undefined
? parseSchema(objectSchema.additionalProperties, {
...refs,
path: [...refs.path, 'additionalProperties'],
})
: undefined;
if (objectSchema.patternProperties) {
const parsedPatternProperties = Object.fromEntries(
Object.entries(objectSchema.patternProperties).map(([key, value]) => {
return [
key,
parseSchema(value, {
...refs,
path: [...refs.path, 'patternProperties', key],
}),
];
}),
);
const patternPropertyValues = Object.values(parsedPatternProperties);
if (propertiesSchema) {
if (additionalProperties) {
zodSchema = propertiesSchema.catchall(
z.union([...patternPropertyValues, additionalProperties] as [z.ZodTypeAny, z.ZodTypeAny]),
);
} else if (Object.keys(parsedPatternProperties).length > 1) {
zodSchema = propertiesSchema.catchall(
z.union(patternPropertyValues as [z.ZodTypeAny, z.ZodTypeAny]),
);
} else {
zodSchema = propertiesSchema.catchall(patternPropertyValues[0]);
}
} else {
if (additionalProperties) {
zodSchema = z.record(
z.union([...patternPropertyValues, additionalProperties] as [z.ZodTypeAny, z.ZodTypeAny]),
);
} else if (patternPropertyValues.length > 1) {
zodSchema = z.record(z.union(patternPropertyValues as [z.ZodTypeAny, z.ZodTypeAny]));
} else {
zodSchema = z.record(patternPropertyValues[0]);
}
}
const objectPropertyKeys = new Set(Object.keys(objectSchema.properties ?? {}));
zodSchema = zodSchema.superRefine((value: Record<string, unknown>, ctx) => {
for (const key in value) {
let wasMatched = objectPropertyKeys.has(key);
for (const patternPropertyKey in objectSchema.patternProperties) {
const regex = new RegExp(patternPropertyKey);
if (key.match(regex)) {
wasMatched = true;
const result = parsedPatternProperties[patternPropertyKey].safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
if (!wasMatched && additionalProperties) {
const result = additionalProperties.safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: 'Invalid input: must match catchall schema',
params: {
issues: result.error.issues,
},
});
}
}
}
});
}
let output: z.ZodTypeAny;
if (propertiesSchema) {
if (hasPatternProperties) {
output = zodSchema!;
} else if (additionalProperties) {
if (additionalProperties instanceof z.ZodNever) {
output = propertiesSchema.strict();
} else {
output = propertiesSchema.catchall(additionalProperties);
}
} else {
output = zodSchema!;
}
} else {
if (hasPatternProperties) {
output = zodSchema!;
} else if (additionalProperties) {
output = z.record(additionalProperties);
} else {
output = z.record(z.any());
}
}
if (its.an.anyOf(objectSchema)) {
output = output.and(
parseAnyOf(
{
...objectSchema,
anyOf: objectSchema.anyOf.map((x) =>
typeof x === 'object' &&
!x.type &&
(x.properties ?? x.additionalProperties ?? x.patternProperties)
? { ...x, type: 'object' }
: x,
),
},
refs,
),
);
}
if (its.a.oneOf(objectSchema)) {
output = output.and(
parseOneOf(
{
...objectSchema,
oneOf: objectSchema.oneOf.map((x) =>
typeof x === 'object' &&
!x.type &&
(x.properties ?? x.additionalProperties ?? x.patternProperties)
? { ...x, type: 'object' }
: x,
),
},
refs,
),
);
}
if (its.an.allOf(objectSchema)) {
output = output.and(
parseAllOf(
{
...objectSchema,
allOf: objectSchema.allOf.map((x) =>
typeof x === 'object' &&
!x.type &&
(x.properties ?? x.additionalProperties ?? x.patternProperties)
? { ...x, type: 'object' }
: x,
),
},
refs,
),
);
}
return output;
}

View file

@ -0,0 +1,41 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseOneOf = (jsonSchema: JsonSchemaObject & { oneOf: JsonSchema[] }, refs: Refs) => {
if (!jsonSchema.oneOf.length) {
return z.any();
}
if (jsonSchema.oneOf.length === 1) {
return parseSchema(jsonSchema.oneOf[0], {
...refs,
path: [...refs.path, 'oneOf', 0],
});
}
return z.any().superRefine((x, ctx) => {
const schemas = jsonSchema.oneOf.map((schema, i) =>
parseSchema(schema, {
...refs,
path: [...refs.path, 'oneOf', i],
}),
);
const unionErrors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(schema.safeParse(x)),
[],
);
if (schemas.length - unionErrors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors,
message: 'Invalid input: Should pass single schema',
});
}
});
};

View file

@ -0,0 +1,130 @@
import * as z from 'zod';
import { parseAllOf } from './parse-all-of';
import { parseAnyOf } from './parse-any-of';
import { parseArray } from './parse-array';
import { parseBoolean } from './parse-boolean';
import { parseConst } from './parse-const';
import { parseDefault } from './parse-default';
import { parseEnum } from './parse-enum';
import { parseIfThenElse } from './parse-if-then-else';
import { parseMultipleType } from './parse-multiple-type';
import { parseNot } from './parse-not';
import { parseNull } from './parse-null';
import { parseNullable } from './parse-nullable';
import { parseNumber } from './parse-number';
import { parseObject } from './parse-object';
import { parseOneOf } from './parse-one-of';
import { parseString } from './parse-string';
import type { ParserSelector, Refs, JsonSchemaObject, JsonSchema } from '../types';
import { its } from '../utils/its';
const addDescribes = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
if (jsonSchema.description) {
zodSchema = zodSchema.describe(jsonSchema.description);
}
return zodSchema;
};
const addDefaults = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
if (jsonSchema.default !== undefined) {
zodSchema = zodSchema.default(jsonSchema.default);
}
return zodSchema;
};
const addAnnotations = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
if (jsonSchema.readOnly) {
zodSchema = zodSchema.readonly();
}
return zodSchema;
};
const selectParser: ParserSelector = (schema, refs) => {
if (its.a.nullable(schema)) {
return parseNullable(schema, refs);
} else if (its.an.object(schema)) {
return parseObject(schema, refs);
} else if (its.an.array(schema)) {
return parseArray(schema, refs);
} else if (its.an.anyOf(schema)) {
return parseAnyOf(schema, refs);
} else if (its.an.allOf(schema)) {
return parseAllOf(schema, refs);
} else if (its.a.oneOf(schema)) {
return parseOneOf(schema, refs);
} else if (its.a.not(schema)) {
return parseNot(schema, refs);
} else if (its.an.enum(schema)) {
return parseEnum(schema); //<-- needs to come before primitives
} else if (its.a.const(schema)) {
return parseConst(schema);
} else if (its.a.multipleType(schema)) {
return parseMultipleType(schema, refs);
} else if (its.a.primitive(schema, 'string')) {
return parseString(schema);
} else if (its.a.primitive(schema, 'number') || its.a.primitive(schema, 'integer')) {
return parseNumber(schema);
} else if (its.a.primitive(schema, 'boolean')) {
return parseBoolean(schema);
} else if (its.a.primitive(schema, 'null')) {
return parseNull(schema);
} else if (its.a.conditional(schema)) {
return parseIfThenElse(schema, refs);
} else {
return parseDefault(schema);
}
};
export const parseSchema = (
jsonSchema: JsonSchema,
refs: Refs = { seen: new Map(), path: [] },
blockMeta?: boolean,
): z.ZodTypeAny => {
if (typeof jsonSchema !== 'object') return jsonSchema ? z.any() : z.never();
if (refs.parserOverride) {
const custom = refs.parserOverride(jsonSchema, refs);
if (custom instanceof z.ZodType) {
return custom;
}
}
let seen = refs.seen.get(jsonSchema);
if (seen) {
if (seen.r !== undefined) {
return seen.r;
}
if (refs.depth === undefined || seen.n >= refs.depth) {
return z.any();
}
seen.n += 1;
} else {
seen = { r: undefined, n: 0 };
refs.seen.set(jsonSchema, seen);
}
let parsedZodSchema = selectParser(jsonSchema, refs);
if (!blockMeta) {
if (!refs.withoutDescribes) {
parsedZodSchema = addDescribes(jsonSchema, parsedZodSchema);
}
if (!refs.withoutDefaults) {
parsedZodSchema = addDefaults(jsonSchema, parsedZodSchema);
}
parsedZodSchema = addAnnotations(jsonSchema, parsedZodSchema);
}
seen.r = parsedZodSchema;
return parsedZodSchema;
};

View file

@ -0,0 +1,58 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
import { extendSchemaWithMessage } from '../utils/extend-schema';
export const parseString = (jsonSchema: JsonSchemaObject & { type: 'string' }) => {
let zodSchema = z.string();
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'format', (zs, format, errorMsg) => {
switch (format) {
case 'email':
return zs.email(errorMsg);
case 'ip':
return zs.ip(errorMsg);
case 'ipv4':
return zs.ip({ version: 'v4', message: errorMsg });
case 'ipv6':
return zs.ip({ version: 'v6', message: errorMsg });
case 'uri':
return zs.url(errorMsg);
case 'uuid':
return zs.uuid(errorMsg);
case 'date-time':
return zs.datetime({ offset: true, message: errorMsg });
case 'time':
return zs.time(errorMsg);
case 'date':
return zs.date(errorMsg);
case 'binary':
return zs.base64(errorMsg);
case 'duration':
return zs.duration(errorMsg);
default:
return zs;
}
});
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'contentEncoding', (zs, _, errorMsg) =>
zs.base64(errorMsg),
);
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'pattern', (zs, pattern, errorMsg) =>
zs.regex(new RegExp(pattern), errorMsg),
);
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minLength',
(zs, minLength, errorMsg) => zs.min(minLength, errorMsg),
);
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maxLength',
(zs, maxLength, errorMsg) => zs.max(maxLength, errorMsg),
);
return zodSchema;
};

View file

@ -0,0 +1,82 @@
import type { ZodTypeAny } from 'zod';
export type Serializable =
| { [key: string]: Serializable }
| Serializable[]
| string
| number
| boolean
| null;
export type JsonSchema = JsonSchemaObject | boolean;
export type JsonSchemaObject = {
// left permissive by design
type?: string | string[];
// object
properties?: { [key: string]: JsonSchema };
additionalProperties?: JsonSchema;
unevaluatedProperties?: JsonSchema;
patternProperties?: { [key: string]: JsonSchema };
minProperties?: number;
maxProperties?: number;
required?: string[] | boolean;
propertyNames?: JsonSchema;
// array
items?: JsonSchema | JsonSchema[];
additionalItems?: JsonSchema;
minItems?: number;
maxItems?: number;
uniqueItems?: boolean;
// string
minLength?: number;
maxLength?: number;
pattern?: string;
format?: string;
// number
minimum?: number;
maximum?: number;
exclusiveMinimum?: number | boolean;
exclusiveMaximum?: number | boolean;
multipleOf?: number;
// unions
anyOf?: JsonSchema[];
allOf?: JsonSchema[];
oneOf?: JsonSchema[];
if?: JsonSchema;
then?: JsonSchema;
else?: JsonSchema;
// shared
const?: Serializable;
enum?: Serializable[];
errorMessage?: { [key: string]: string | undefined };
description?: string;
default?: Serializable;
readOnly?: boolean;
not?: JsonSchema;
contentEncoding?: string;
nullable?: boolean;
};
export type ParserSelector = (schema: JsonSchemaObject, refs: Refs) => ZodTypeAny;
export type ParserOverride = (schema: JsonSchemaObject, refs: Refs) => ZodTypeAny | undefined;
export type JsonSchemaToZodOptions = {
withoutDefaults?: boolean;
withoutDescribes?: boolean;
parserOverride?: ParserOverride;
depth?: number;
};
export type Refs = JsonSchemaToZodOptions & {
path: Array<string | number>;
seen: Map<object | boolean, { n: number; r: ZodTypeAny | undefined }>;
};

View file

@ -0,0 +1,23 @@
import type { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export function extendSchemaWithMessage<
TZod extends z.ZodTypeAny,
TJson extends JsonSchemaObject,
TKey extends keyof TJson,
>(
zodSchema: TZod,
jsonSchema: TJson,
key: TKey,
extend: (zodSchema: TZod, value: NonNullable<TJson[TKey]>, errorMessage?: string) => TZod,
) {
const value = jsonSchema[key];
if (value !== undefined) {
const errorMessage = jsonSchema.errorMessage?.[key as string];
return extend(zodSchema, value as NonNullable<TJson[TKey]>, errorMessage);
}
return zodSchema;
}

View file

@ -0,0 +1,3 @@
export const half = <T>(arr: T[]): [T[], T[]] => {
return [arr.slice(0, arr.length / 2), arr.slice(arr.length / 2)];
};

View file

@ -0,0 +1,57 @@
import type { JsonSchema, JsonSchemaObject, Serializable } from '../types';
export const its = {
an: {
object: (x: JsonSchemaObject): x is JsonSchemaObject & { type: 'object' } =>
x.type === 'object',
array: (x: JsonSchemaObject): x is JsonSchemaObject & { type: 'array' } => x.type === 'array',
anyOf: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
anyOf: JsonSchema[];
} => x.anyOf !== undefined,
allOf: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
allOf: JsonSchema[];
} => x.allOf !== undefined,
enum: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
enum: Serializable | Serializable[];
} => x.enum !== undefined,
},
a: {
nullable: (x: JsonSchemaObject): x is JsonSchemaObject & { nullable: true } =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(x as any).nullable === true,
multipleType: (x: JsonSchemaObject): x is JsonSchemaObject & { type: string[] } =>
Array.isArray(x.type),
not: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
not: JsonSchema;
} => x.not !== undefined,
const: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
const: Serializable;
} => x.const !== undefined,
primitive: <T extends 'string' | 'number' | 'integer' | 'boolean' | 'null'>(
x: JsonSchemaObject,
p: T,
): x is JsonSchemaObject & { type: T } => x.type === p,
conditional: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
if: JsonSchema;
then: JsonSchema;
else: JsonSchema;
} => Boolean('if' in x && x.if && 'then' in x && 'else' in x && x.then && x.else),
oneOf: (
x: JsonSchemaObject,
): x is JsonSchemaObject & {
oneOf: JsonSchema[];
} => x.oneOf !== undefined,
},
};

View file

@ -0,0 +1,8 @@
export const omit = <T extends object, K extends keyof T>(obj: T, ...keys: K[]): Omit<T, K> =>
Object.keys(obj).reduce((acc: Record<string, unknown>, key) => {
if (!keys.includes(key as K)) {
acc[key] = obj[key as K];
}
return acc;
}, {}) as Omit<T, K>;

View file

@ -0,0 +1,143 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"properties": {
"allOf": {
"allOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
]
},
"anyOf": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
]
},
"oneOf": {
"oneOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
]
},
"array": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 2,
"maxItems": 3
},
"tuple": {
"type": "array",
"items": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
}
],
"minItems": 2,
"maxItems": 3
},
"const": {
"const": "xbox"
},
"enum": {
"enum": ["ps4", "ps5"]
},
"ifThenElse": {
"if": {
"type": "string"
},
"then": {
"const": "x"
},
"else": {
"enum": [1, 2, 3]
}
},
"null": {
"type": "null"
},
"multiple": {
"type": ["array", "boolean"]
},
"objAdditionalTrue": {
"type": "object",
"properties": {
"x": {
"type": "string"
}
},
"additionalProperties": true
},
"objAdditionalFalse": {
"type": "object",
"properties": {
"x": {
"type": "string"
}
},
"additionalProperties": false
},
"objAdditionalNumber": {
"type": "object",
"properties": {
"x": {
"type": "string"
}
},
"additionalProperties": {
"type": "number"
}
},
"objAdditionalOnly": {
"type": "object",
"additionalProperties": {
"type": "number"
}
},
"patternProps": {
"type": "object",
"patternProperties": {
"^x": {
"type": "string"
},
"^y": {
"type": "number"
}
},
"properties": {
"z": {
"type": "string"
}
},
"additionalProperties": false
}
}
}

View file

@ -0,0 +1,16 @@
import type { z } from 'zod';
expect.extend({
toMatchZod(this: jest.MatcherContext, actual: z.ZodTypeAny, expected: z.ZodTypeAny) {
const actualSerialized = JSON.stringify(actual._def, null, 2);
const expectedSerialized = JSON.stringify(expected._def, null, 2);
const pass = this.equals(actualSerialized, expectedSerialized);
return {
pass,
message: pass
? () => `Expected ${actualSerialized} not to match ${expectedSerialized}`
: () => `Expected ${actualSerialized} to match ${expectedSerialized}`,
};
},
});

View file

@ -0,0 +1,5 @@
namespace jest {
interface Matchers<R, T> {
toMatchZod(expected: unknown): T;
}
}

View file

@ -0,0 +1,106 @@
import type { JSONSchema4, JSONSchema6Definition, JSONSchema7Definition } from 'json-schema';
import { z } from 'zod';
import { jsonSchemaToZod } from '../src';
describe('jsonSchemaToZod', () => {
test('should accept json schema 7 and 4', () => {
const schema = { type: 'string' } as unknown;
expect(jsonSchemaToZod(schema as JSONSchema4));
expect(jsonSchemaToZod(schema as JSONSchema6Definition));
expect(jsonSchemaToZod(schema as JSONSchema7Definition));
});
test('can exclude defaults', () => {
expect(
jsonSchemaToZod(
{
type: 'string',
default: 'foo',
},
{ withoutDefaults: true },
),
).toMatchZod(z.string());
});
test('should include describes', () => {
expect(
jsonSchemaToZod({
type: 'string',
description: 'foo',
}),
).toMatchZod(z.string().describe('foo'));
});
test('can exclude describes', () => {
expect(
jsonSchemaToZod(
{
type: 'string',
description: 'foo',
},
{
withoutDescribes: true,
},
),
).toMatchZod(z.string());
});
test('will remove optionality if default is present', () => {
expect(
jsonSchemaToZod({
type: 'object',
properties: {
prop: {
type: 'string',
default: 'def',
},
},
}),
).toMatchZod(z.object({ prop: z.string().default('def') }));
});
test('will handle falsy defaults', () => {
expect(
jsonSchemaToZod({
type: 'boolean',
default: false,
}),
).toMatchZod(z.boolean().default(false));
});
test('will ignore undefined as default', () => {
expect(
jsonSchemaToZod({
type: 'null',
default: undefined,
}),
).toMatchZod(z.null());
});
test('should be possible to define a custom parser', () => {
expect(
jsonSchemaToZod(
{
allOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean', description: 'foo' }],
},
{
parserOverride: (schema, refs) => {
if (
refs.path.length === 2 &&
refs.path[0] === 'allOf' &&
refs.path[1] === 2 &&
schema.type === 'boolean' &&
schema.description === 'foo'
) {
return z.null();
}
return undefined;
},
},
),
).toMatchZod(z.intersection(z.string(), z.intersection(z.number(), z.null())));
});
});

View file

@ -0,0 +1,48 @@
import { z } from 'zod';
import { parseAllOf } from '../../src/parsers/parse-all-of';
describe('parseAllOf', () => {
test('should create never if empty', () => {
expect(
parseAllOf(
{
allOf: [],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.never());
});
test('should handle true values', () => {
expect(
parseAllOf(
{
allOf: [{ type: 'string' }, true],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.intersection(z.string(), z.any()));
});
test('should handle false values', () => {
expect(
parseAllOf(
{
allOf: [{ type: 'string' }, false],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.intersection(
z.string(),
z
.any()
.refine(
(value) => !z.any().safeParse(value).success,
'Invalid input: Should NOT be valid against schema',
),
),
);
});
});

View file

@ -0,0 +1,31 @@
import { z } from 'zod';
import { parseAnyOf } from '../../src/parsers/parse-any-of';
describe('parseAnyOf', () => {
test('should create a union from two or more schemas', () => {
expect(
parseAnyOf(
{
anyOf: [
{
type: 'string',
},
{ type: 'number' },
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.union([z.string(), z.number()]));
});
test('should extract a single schema', () => {
expect(parseAnyOf({ anyOf: [{ type: 'string' }] }, { path: [], seen: new Map() })).toMatchZod(
z.string(),
);
});
test('should return z.any() if array is empty', () => {
expect(parseAnyOf({ anyOf: [] }, { path: [], seen: new Map() })).toMatchZod(z.any());
});
});

View file

@ -0,0 +1,68 @@
import { z } from 'zod';
import { parseArray } from '../../src/parsers/parse-array';
describe('parseArray', () => {
test('should create tuple with items array', () => {
expect(
parseArray(
{
type: 'array',
items: [
{
type: 'string',
},
{
type: 'number',
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.tuple([z.string(), z.number()]));
});
test('should create array with items object', () => {
expect(
parseArray(
{
type: 'array',
items: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.array(z.string()));
});
test('should create min for minItems', () => {
expect(
parseArray(
{
type: 'array',
minItems: 2,
items: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.array(z.string()).min(2));
});
test('should create max for maxItems', () => {
expect(
parseArray(
{
type: 'array',
maxItems: 2,
items: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.array(z.string()).max(2));
});
});

View file

@ -0,0 +1,13 @@
import { z } from 'zod';
import { parseConst } from '../../src/parsers/parse-const';
describe('parseConst', () => {
test('should handle falsy constants', () => {
expect(
parseConst({
const: false,
}),
).toMatchZod(z.literal(false));
});
});

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
import { parseEnum } from '../../src/parsers/parse-enum';
describe('parseEnum', () => {
test('should create never with empty enum', () => {
expect(
parseEnum({
enum: [],
}),
).toMatchZod(z.never());
});
test('should create literal with single item enum', () => {
expect(
parseEnum({
enum: ['someValue'],
}),
).toMatchZod(z.literal('someValue'));
});
test('should create enum array with string enums', () => {
expect(
parseEnum({
enum: ['someValue', 'anotherValue'],
}),
).toMatchZod(z.enum(['someValue', 'anotherValue']));
});
test('should create union with mixed enums', () => {
expect(
parseEnum({
enum: ['someValue', 57],
}),
).toMatchZod(z.union([z.literal('someValue'), z.literal(57)]));
});
});

View file

@ -0,0 +1,25 @@
import { z } from 'zod';
import { parseNot } from '../../src/parsers/parse-not';
describe('parseNot', () => {
test('parseNot', () => {
expect(
parseNot(
{
not: {
type: 'string',
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.any()
.refine(
(value) => !z.string().safeParse(value).success,
'Invalid input: Should NOT be valid against schema',
),
);
});
});

View file

@ -0,0 +1,18 @@
import { z } from 'zod';
import { parseSchema } from '../../src/parsers/parse-schema';
describe('parseNullable', () => {
test('parseSchema should not add default twice', () => {
expect(
parseSchema(
{
type: 'string',
nullable: true,
default: null,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.string().nullable().default(null));
});
});

View file

@ -0,0 +1,83 @@
import { z } from 'zod';
import { parseNumber } from '../../src/parsers/parse-number';
describe('parseNumber', () => {
test('should handle integer', () => {
expect(
parseNumber({
type: 'integer',
}),
).toMatchZod(z.number().int());
expect(
parseNumber({
type: 'integer',
multipleOf: 1,
}),
).toMatchZod(z.number().int());
expect(
parseNumber({
type: 'number',
multipleOf: 1,
}),
).toMatchZod(z.number().int());
});
test('should handle maximum with exclusiveMinimum', () => {
expect(
parseNumber({
type: 'number',
exclusiveMinimum: true,
minimum: 2,
}),
).toMatchZod(z.number().gt(2));
});
test('should handle maximum with exclusiveMinimum', () => {
expect(
parseNumber({
type: 'number',
minimum: 2,
}),
).toMatchZod(z.number().gte(2));
});
test('should handle maximum with exclusiveMaximum', () => {
expect(
parseNumber({
type: 'number',
exclusiveMaximum: true,
maximum: 2,
}),
).toMatchZod(z.number().lt(2));
});
test('should handle numeric exclusiveMaximum', () => {
expect(
parseNumber({
type: 'number',
exclusiveMaximum: 2,
}),
).toMatchZod(z.number().lt(2));
});
test('should accept errorMessage', () => {
expect(
parseNumber({
type: 'number',
format: 'int64',
exclusiveMinimum: 0,
maximum: 2,
multipleOf: 2,
errorMessage: {
format: 'ayy',
multipleOf: 'lmao',
exclusiveMinimum: 'deez',
maximum: 'nuts',
},
}),
).toMatchZod(z.number().int('ayy').multipleOf(2, 'lmao').gt(0, 'deez').lte(2, 'nuts'));
});
});

View file

@ -0,0 +1,904 @@
/* eslint-disable n8n-local-rules/no-skipped-tests */
import type { JSONSchema7 } from 'json-schema';
import { z, ZodError } from 'zod';
import { parseObject } from '../../src/parsers/parse-object';
describe('parseObject', () => {
test('should handle with missing properties', () => {
expect(
parseObject(
{
type: 'object',
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.any()));
});
test('should handle with empty properties', () => {
expect(
parseObject(
{
type: 'object',
properties: {},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({}));
});
test('With properties - should handle optional and required properties', () => {
expect(
parseObject(
{
type: 'object',
required: ['myRequiredString'],
properties: {
myOptionalString: {
type: 'string',
},
myRequiredString: {
type: 'string',
},
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ myOptionalString: z.string().optional(), myRequiredString: z.string() }),
);
});
test('With properties - should handle additionalProperties when set to false', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: false,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).strict());
});
test('With properties - should handle additionalProperties when set to true', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).catchall(z.any()));
});
test('With properties - should handle additionalProperties when provided a schema', () => {
expect(
parseObject(
{
type: 'object',
required: ['myString'],
properties: {
myString: {
type: 'string',
},
},
additionalProperties: { type: 'number' },
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ myString: z.string() }).catchall(z.number()));
});
test('Without properties - should handle additionalProperties when set to false', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: false,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.never()));
});
test('Without properties - should handle additionalProperties when set to true', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.any()));
});
test('Without properties - should handle additionalProperties when provided a schema', () => {
expect(
parseObject(
{
type: 'object',
additionalProperties: { type: 'number' },
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.record(z.number()));
});
test('Without properties - should include falsy defaults', () => {
expect(
parseObject(
{
type: 'object',
properties: {
s: {
type: 'string',
default: '',
},
},
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ s: z.string().default('') }));
});
test('eh', () => {
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
anyOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.object({ a: z.string() })
.and(z.union([z.object({ b: z.string() }), z.object({ c: z.string() })])),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
anyOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.object({ a: z.string() }).and(z.union([z.object({ b: z.string() }), z.any()])));
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
oneOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(
z.any().superRefine((x, ctx) => {
const schemas = [z.object({ b: z.string() }), z.object({ c: z.string() })];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(
schema.safeParse(x),
),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
oneOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(
z.any().superRefine((x, ctx) => {
const schemas = [z.object({ b: z.string() }), z.any()];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(
schema.safeParse(x),
),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
allOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{
required: ['c'],
properties: {
c: {
type: 'string',
},
},
},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z
.object({ a: z.string() })
.and(z.intersection(z.object({ b: z.string() }), z.object({ c: z.string() }))),
);
expect(
parseObject(
{
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
},
allOf: [
{
required: ['b'],
properties: {
b: {
type: 'string',
},
},
},
{},
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.object({ a: z.string() }).and(z.intersection(z.object({ b: z.string() }), z.any())),
);
});
const run = (zodSchema: z.ZodTypeAny, data: unknown) => zodSchema.safeParse(data);
test('Functional tests - run', () => {
expect(run(z.string(), 'hello')).toEqual({
success: true,
data: 'hello',
});
});
test('Functional tests - properties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
};
const expected = z.object({ a: z.string(), b: z.number().optional() });
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { a: 'hello' })).toEqual({
success: true,
data: {
a: 'hello',
},
});
expect(run(result, { a: 'hello', b: 123 })).toEqual({
success: true,
data: {
a: 'hello',
b: 123,
},
});
expect(run(result, { b: 'hello', x: true })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['a'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'number',
received: 'string',
path: ['b'],
message: 'Expected number, received string',
},
]),
});
});
test('Functional tests - properties and additionalProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
additionalProperties: { type: 'boolean' },
};
const expected = z.object({ a: z.string(), b: z.number().optional() }).catchall(z.boolean());
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { b: 'hello', x: 'true' })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'string',
received: 'undefined',
path: ['a'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'number',
received: 'string',
path: ['b'],
message: 'Expected number, received string',
},
{
code: 'invalid_type',
expected: 'boolean',
received: 'string',
path: ['x'],
message: 'Expected boolean, received string',
},
]),
});
});
test('Functional tests - properties and single-item patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
patternProperties: {
'\\.': { type: 'array' },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.array(z.any()))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { a: 'a', b: 2, '.': [] })).toEqual({
success: true,
data: { a: 'a', b: 2, '.': [] },
});
expect(run(result, { a: 'a', b: 2, '.': '[]' })).toEqual({
success: false,
error: new ZodError([
{
code: 'invalid_type',
expected: 'array',
received: 'string',
path: ['.'],
message: 'Expected array, received string',
},
]),
});
});
test('Functional tests - properties, additionalProperties and patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
additionalProperties: { type: 'boolean' },
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.union([z.array(z.any()), z.array(z.any()).min(1), z.boolean()]))
.superRefine((value, ctx) => {
for (const key in value) {
let evaluated = ['a', 'b'].includes(key);
if (key.match(new RegExp('\\\\.'))) {
evaluated = true;
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\\\,'))) {
evaluated = true;
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (!evaluated) {
const result = z.boolean().safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: 'Invalid input: must match catchall schema',
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - additionalProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
additionalProperties: { type: 'boolean' },
};
const expected = z.record(z.boolean());
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - additionalProperties and patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
additionalProperties: { type: 'boolean' },
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.record(z.union([z.array(z.any()), z.array(z.any()).min(1), z.boolean()]))
.superRefine((value, ctx) => {
for (const key in value) {
let evaluated = false;
if (key.match(new RegExp('\\\\.'))) {
evaluated = true;
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\\\,'))) {
evaluated = true;
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (!evaluated) {
const result = z.boolean().safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: 'Invalid input: must match catchall schema',
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
expect(run(result, { x: true, '.': [], ',': [] })).toEqual({
success: false,
error: new ZodError([
{
path: [','],
code: 'custom',
message: 'Invalid input: Key matching regex /,/ must match schema',
params: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'array',
inclusive: true,
exact: false,
message: 'Array must contain at least 1 element(s)',
path: [],
},
],
},
},
]),
});
});
test('Functional tests - single-item patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
patternProperties: {
'\\.': { type: 'array' },
},
};
const expected = z.record(z.array(z.any())).superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
test('Functional tests - patternProperties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.record(z.union([z.array(z.any()), z.array(z.any()).min(1)]))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\,'))) {
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(run(result, { '.': [] })).toEqual({
success: true,
data: { '.': [] },
});
expect(run(result, { ',': [] })).toEqual({
success: false,
error: new ZodError([
{
path: [','],
code: 'custom',
message: 'Invalid input: Key matching regex /,/ must match schema',
params: {
issues: [
{
code: 'too_small',
minimum: 1,
type: 'array',
inclusive: true,
exact: false,
message: 'Array must contain at least 1 element(s)',
path: [],
},
],
},
},
]),
});
expect(result).toMatchZod(expected);
});
test('Functional tests - patternProperties and properties', () => {
const schema: JSONSchema7 & { type: 'object' } = {
type: 'object',
required: ['a'],
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
patternProperties: {
'\\.': { type: 'array' },
'\\,': { type: 'array', minItems: 1 },
},
};
const expected = z
.object({ a: z.string(), b: z.number().optional() })
.catchall(z.union([z.array(z.any()), z.array(z.any()).min(1)]))
.superRefine((value, ctx) => {
for (const key in value) {
if (key.match(new RegExp('\\.'))) {
const result = z.array(z.any()).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
if (key.match(new RegExp('\\,'))) {
const result = z.array(z.any()).min(1).safeParse(value[key]);
if (!result.success) {
ctx.addIssue({
path: [...ctx.path, key],
code: 'custom',
message: `Invalid input: Key matching regex /${key}/ must match schema`,
params: {
issues: result.error.issues,
},
});
}
}
}
});
const result = parseObject(schema, { path: [], seen: new Map() });
expect(result).toMatchZod(expected);
});
});

View file

@ -0,0 +1,48 @@
import { z } from 'zod';
import { parseOneOf } from '../../src/parsers/parse-one-of';
describe('parseOneOf', () => {
test('should create a union from two or more schemas', () => {
expect(
parseOneOf(
{
oneOf: [
{
type: 'string',
},
{ type: 'number' },
],
},
{ path: [], seen: new Map() },
),
).toMatchZod(
z.any().superRefine((x, ctx) => {
const schemas = [z.string(), z.number()];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(schema.safeParse(x)),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
);
});
test('should extract a single schema', () => {
expect(parseOneOf({ oneOf: [{ type: 'string' }] }, { path: [], seen: new Map() })).toMatchZod(
z.string(),
);
});
test('should return z.any() if array is empty', () => {
expect(parseOneOf({ oneOf: [] }, { path: [], seen: new Map() })).toMatchZod(z.any());
});
});

View file

@ -0,0 +1,113 @@
import { z } from 'zod';
import { parseSchema } from '../../src/parsers/parse-schema';
describe('parseSchema', () => {
test('should be usable without providing refs', () => {
expect(parseSchema({ type: 'string' })).toMatchZod(z.string());
});
test('should return a seen and processed ref', () => {
const seen = new Map();
const schema = {
type: 'object',
properties: {
prop: {
type: 'string',
},
},
};
expect(parseSchema(schema, { seen, path: [] }));
expect(parseSchema(schema, { seen, path: [] }));
});
test('should be possible to describe a readonly schema', () => {
expect(parseSchema({ type: 'string', readOnly: true })).toMatchZod(z.string().readonly());
});
test('should handle nullable', () => {
expect(
parseSchema(
{
type: 'string',
nullable: true,
},
{ path: [], seen: new Map() },
),
).toMatchZod(z.string().nullable());
});
test('should handle enum', () => {
expect(parseSchema({ enum: ['someValue', 57] })).toMatchZod(
z.union([z.literal('someValue'), z.literal(57)]),
);
});
test('should handle multiple type', () => {
expect(parseSchema({ type: ['string', 'number'] })).toMatchZod(
z.union([z.string(), z.number()]),
);
});
test('should handle if-then-else type', () => {
expect(
parseSchema({
if: { type: 'string' },
then: { type: 'number' },
else: { type: 'boolean' },
}),
).toMatchZod(
z.union([z.number(), z.boolean()]).superRefine((value, ctx) => {
const result = z.string().safeParse(value).success
? z.number().safeParse(value)
: z.boolean().safeParse(value);
if (!result.success) {
result.error.errors.forEach((error) => ctx.addIssue(error));
}
}),
);
});
test('should handle anyOf', () => {
expect(
parseSchema({
anyOf: [
{
type: 'string',
},
{ type: 'number' },
],
}),
).toMatchZod(z.union([z.string(), z.number()]));
});
test('should handle oneOf', () => {
expect(
parseSchema({
oneOf: [
{
type: 'string',
},
{ type: 'number' },
],
}),
).toMatchZod(
z.any().superRefine((x, ctx) => {
const schemas = [z.string(), z.number()];
const errors = schemas.reduce<z.ZodError[]>(
(errors, schema) =>
((result) => (result.error ? [...errors, result.error] : errors))(schema.safeParse(x)),
[],
);
if (schemas.length - errors.length !== 1) {
ctx.addIssue({
path: ctx.path,
code: 'invalid_union',
unionErrors: errors,
message: 'Invalid input: Should pass single schema',
});
}
}),
);
});
});

View file

@ -0,0 +1,152 @@
import { z } from 'zod';
import { parseString } from '../../src/parsers/parse-string';
describe('parseString', () => {
const run = (schema: z.ZodString, data: unknown) => schema.safeParse(data);
test('DateTime format', () => {
const datetime = '2018-11-13T20:20:39Z';
const code = parseString({
type: 'string',
format: 'date-time',
errorMessage: { format: 'hello' },
});
expect(code).toMatchZod(z.string().datetime({ offset: true, message: 'hello' }));
expect(run(code, datetime)).toEqual({ success: true, data: datetime });
});
test('email', () => {
expect(
parseString({
type: 'string',
format: 'email',
}),
).toMatchZod(z.string().email());
});
test('ip', () => {
expect(
parseString({
type: 'string',
format: 'ip',
}),
).toMatchZod(z.string().ip());
expect(
parseString({
type: 'string',
format: 'ipv6',
}),
).toMatchZod(z.string().ip({ version: 'v6' }));
});
test('uri', () => {
expect(
parseString({
type: 'string',
format: 'uri',
}),
).toMatchZod(z.string().url());
});
test('uuid', () => {
expect(
parseString({
type: 'string',
format: 'uuid',
}),
).toMatchZod(z.string().uuid());
});
test('time', () => {
expect(
parseString({
type: 'string',
format: 'time',
}),
).toMatchZod(z.string().time());
});
test('date', () => {
expect(
parseString({
type: 'string',
format: 'date',
}),
).toMatchZod(z.string().date());
});
test('duration', () => {
expect(
parseString({
type: 'string',
format: 'duration',
}),
).toMatchZod(z.string().duration());
});
test('base64', () => {
expect(
parseString({
type: 'string',
contentEncoding: 'base64',
}),
).toMatchZod(z.string().base64());
expect(
parseString({
type: 'string',
contentEncoding: 'base64',
errorMessage: {
contentEncoding: 'x',
},
}),
).toMatchZod(z.string().base64('x'));
expect(
parseString({
type: 'string',
format: 'binary',
}),
).toMatchZod(z.string().base64());
expect(
parseString({
type: 'string',
format: 'binary',
errorMessage: {
format: 'x',
},
}),
).toMatchZod(z.string().base64('x'));
});
test('should accept errorMessage', () => {
expect(
parseString({
type: 'string',
format: 'ipv4',
pattern: 'x',
minLength: 1,
maxLength: 2,
errorMessage: {
format: 'ayy',
pattern: 'lmao',
minLength: 'deez',
maxLength: 'nuts',
},
}),
).toMatchZod(
z
.string()
.ip({ version: 'v4', message: 'ayy' })
.regex(new RegExp('x'), 'lmao')
.min(1, 'deez')
.max(2, 'nuts'),
);
});
});

View file

@ -0,0 +1,15 @@
import { half } from '../../src/utils/half';
describe('half', () => {
test('half', () => {
const [a, b] = half(['A', 'B', 'C', 'D', 'E']);
if (1 < 0) {
// type should be string
a[0].endsWith('');
}
expect(a).toEqual(['A', 'B']);
expect(b).toEqual(['C', 'D', 'E']);
});
});

View file

@ -0,0 +1,27 @@
import { omit } from '../../src/utils/omit';
describe('omit', () => {
test('omit', () => {
const input = {
a: true,
b: true,
};
omit(
input,
'b',
// @ts-expect-error
'c',
);
const output = omit(input, 'b');
// @ts-expect-error
output.b;
expect(output.a).toBe(true);
// @ts-expect-error
expect(output.b).toBeUndefined();
});
});

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "dist/cjs",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "es2020",
"moduleResolution": "node",
"outDir": "dist/esm",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}

View file

@ -0,0 +1,12 @@
{
"extends": ["../../../tsconfig.json"],
"compilerOptions": {
"rootDir": ".",
"baseUrl": "src",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}

View file

@ -8,7 +8,7 @@ import type {
INodeTypeDescription,
INodeProperties,
} from 'n8n-workflow';
import { promptTypeOptions, textInput } from '../../../utils/descriptions';
import { conversationalAgentProperties } from './agents/ConversationalAgent/description';
import { conversationalAgentExecute } from './agents/ConversationalAgent/execute';
import { openAiFunctionsAgentProperties } from './agents/OpenAiFunctionsAgent/description';
@ -21,6 +21,7 @@ import { sqlAgentAgentProperties } from './agents/SqlAgent/description';
import { sqlAgentAgentExecute } from './agents/SqlAgent/execute';
import { toolsAgentProperties } from './agents/ToolsAgent/description';
import { toolsAgentExecute } from './agents/ToolsAgent/execute';
import { promptTypeOptions, textInput } from '../../../utils/descriptions';
// Function used in the inputs expression to figure out which inputs to
// display based on the agent type
@ -351,6 +352,23 @@ export class Agent implements INodeType {
},
},
},
{
displayName: 'For more reliable structured output parsing, consider using the Tools agent',
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
hasOutputParser: [true],
agent: [
'conversationalAgent',
'reActAgent',
'planAndExecuteAgent',
'openAiFunctionsAgent',
],
},
},
},
{
displayName: 'Require Specific Output Format',
name: 'hasOutputParser',
@ -372,6 +390,7 @@ export class Agent implements INodeType {
displayOptions: {
show: {
hasOutputParser: [true],
agent: ['toolsAgent'],
},
},
},

View file

@ -1,19 +1,19 @@
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { initializeAgentExecutorWithOptions } from 'langchain/agents';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { initializeAgentExecutorWithOptions } from 'langchain/agents';
import { CombiningOutputParser } from 'langchain/output_parsers';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import {
isChatInstance,
getPromptInputByType,
getOptionalOutputParsers,
getConnectedTools,
} from '../../../../../utils/helpers';
import { getTracingConfig } from '../../../../../utils/tracing';
import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser';
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
import { getTracingConfig } from '../../../../../utils/tracing';
export async function conversationalAgentExecute(
this: IExecuteFunctions,

View file

@ -1,3 +1,10 @@
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { ChatOpenAI } from '@langchain/openai';
import type { AgentExecutorInput } from 'langchain/agents';
import { AgentExecutor, OpenAIAgent } from 'langchain/agents';
import { BufferMemory, type BaseChatMemory } from 'langchain/memory';
import { CombiningOutputParser } from 'langchain/output_parsers';
import {
type IExecuteFunctions,
type INodeExecutionData,
@ -5,18 +12,8 @@ import {
NodeOperationError,
} from 'n8n-workflow';
import type { AgentExecutorInput } from 'langchain/agents';
import { AgentExecutor, OpenAIAgent } from 'langchain/agents';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { CombiningOutputParser } from 'langchain/output_parsers';
import { BufferMemory, type BaseChatMemory } from 'langchain/memory';
import { ChatOpenAI } from '@langchain/openai';
import {
getConnectedTools,
getOptionalOutputParsers,
getPromptInputByType,
} from '../../../../../utils/helpers';
import { getConnectedTools, getPromptInputByType } from '../../../../../utils/helpers';
import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser';
import { getTracingConfig } from '../../../../../utils/tracing';
export async function openAiFunctionsAgentExecute(

View file

@ -1,3 +1,8 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { PlanAndExecuteAgentExecutor } from 'langchain/experimental/plan_and_execute';
import { CombiningOutputParser } from 'langchain/output_parsers';
import {
type IExecuteFunctions,
type INodeExecutionData,
@ -5,18 +10,10 @@ import {
NodeOperationError,
} from 'n8n-workflow';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { CombiningOutputParser } from 'langchain/output_parsers';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { PlanAndExecuteAgentExecutor } from 'langchain/experimental/plan_and_execute';
import {
getConnectedTools,
getOptionalOutputParsers,
getPromptInputByType,
} from '../../../../../utils/helpers';
import { getTracingConfig } from '../../../../../utils/tracing';
import { getConnectedTools, getPromptInputByType } from '../../../../../utils/helpers';
import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser';
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
import { getTracingConfig } from '../../../../../utils/tracing';
export async function planAndExecuteAgentExecute(
this: IExecuteFunctions,

View file

@ -1,3 +1,9 @@
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { AgentExecutor, ChatAgent, ZeroShotAgent } from 'langchain/agents';
import { CombiningOutputParser } from 'langchain/output_parsers';
import {
type IExecuteFunctions,
type INodeExecutionData,
@ -5,20 +11,14 @@ import {
NodeOperationError,
} from 'n8n-workflow';
import { AgentExecutor, ChatAgent, ZeroShotAgent } from 'langchain/agents';
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';
import { CombiningOutputParser } from 'langchain/output_parsers';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import {
getConnectedTools,
getOptionalOutputParsers,
getPromptInputByType,
isChatInstance,
} from '../../../../../utils/helpers';
import { getTracingConfig } from '../../../../../utils/tracing';
import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser';
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
import { getTracingConfig } from '../../../../../utils/tracing';
export async function reActAgentAgentExecute(
this: IExecuteFunctions,

Some files were not shown because too many files have changed in this diff Show more