mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into sec-143-cross-site-scripting-cwe-79
This commit is contained in:
commit
22440937bf
6
.github/workflows/e2e-reusable.yml
vendored
6
.github/workflows/e2e-reusable.yml
vendored
|
@ -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
|
||||
|
|
6
.github/workflows/e2e-tests.yml
vendored
6
.github/workflows/e2e-tests.yml
vendored
|
@ -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 }}
|
||||
|
||||
|
|
39
CHANGELOG.md
39
CHANGELOG.md
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
86
cypress/e2e/2372-ado-prevent-clipping-params.cy.ts
Normal file
86
cypress/e2e/2372-ado-prevent-clipping-params.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
150
cypress/fixtures/Test-workflow-with-long-parameters.json
Normal file
150
cypress/fixtures/Test-workflow-with-long-parameters.json
Normal 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": {}
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.63.0",
|
||||
"version": "1.64.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
15
packages/@n8n/config/src/configs/generic.config.ts
Normal file
15
packages/@n8n/config/src/configs/generic.config.ts
Normal 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;
|
||||
}
|
16
packages/@n8n/config/src/configs/multi-main-setup.config.ts
Normal file
16
packages/@n8n/config/src/configs/multi-main-setup.config.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
21
packages/@n8n/json-schema-to-zod/.eslintrc.js
Normal file
21
packages/@n8n/json-schema-to-zod/.eslintrc.js
Normal 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',
|
||||
},
|
||||
};
|
4
packages/@n8n/json-schema-to-zod/.gitignore
vendored
Normal file
4
packages/@n8n/json-schema-to-zod/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
test/output
|
3
packages/@n8n/json-schema-to-zod/.npmignore
Normal file
3
packages/@n8n/json-schema-to-zod/.npmignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
src
|
||||
tsconfig*
|
||||
test
|
16
packages/@n8n/json-schema-to-zod/LICENSE
Normal file
16
packages/@n8n/json-schema-to-zod/LICENSE
Normal 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.
|
34
packages/@n8n/json-schema-to-zod/README.md
Normal file
34
packages/@n8n/json-schema-to-zod/README.md
Normal 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).
|
5
packages/@n8n/json-schema-to-zod/jest.config.js
Normal file
5
packages/@n8n/json-schema-to-zod/jest.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...require('../../../jest.config'),
|
||||
setupFilesAfterEnv: ['<rootDir>/test/extend-expect.ts'],
|
||||
};
|
69
packages/@n8n/json-schema-to-zod/package.json
Normal file
69
packages/@n8n/json-schema-to-zod/package.json
Normal 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:"
|
||||
}
|
||||
}
|
1
packages/@n8n/json-schema-to-zod/postcjs.js
Normal file
1
packages/@n8n/json-schema-to-zod/postcjs.js
Normal file
|
@ -0,0 +1 @@
|
|||
require('fs').writeFileSync('./dist/cjs/package.json', '{"type":"commonjs"}', 'utf-8');
|
1
packages/@n8n/json-schema-to-zod/postesm.js
Normal file
1
packages/@n8n/json-schema-to-zod/postesm.js
Normal file
|
@ -0,0 +1 @@
|
|||
require('fs').writeFileSync('./dist/esm/package.json', '{"type":"module"}', 'utf-8');
|
2
packages/@n8n/json-schema-to-zod/src/index.ts
Normal file
2
packages/@n8n/json-schema-to-zod/src/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export type * from './types';
|
||||
export { jsonSchemaToZod } from './json-schema-to-zod.js';
|
15
packages/@n8n/json-schema-to-zod/src/json-schema-to-zod.ts
Normal file
15
packages/@n8n/json-schema-to-zod/src/json-schema-to-zod.ts
Normal 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;
|
||||
};
|
46
packages/@n8n/json-schema-to-zod/src/parsers/parse-all-of.ts
Normal file
46
packages/@n8n/json-schema-to-zod/src/parsers/parse-all-of.ts
Normal 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));
|
||||
}
|
19
packages/@n8n/json-schema-to-zod/src/parsers/parse-any-of.ts
Normal file
19
packages/@n8n/json-schema-to-zod/src/parsers/parse-any-of.ts
Normal 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();
|
||||
};
|
34
packages/@n8n/json-schema-to-zod/src/parsers/parse-array.ts
Normal file
34
packages/@n8n/json-schema-to-zod/src/parsers/parse-array.ts
Normal 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;
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { JsonSchemaObject } from '../types';
|
||||
|
||||
export const parseBoolean = (_jsonSchema: JsonSchemaObject & { type: 'boolean' }) => {
|
||||
return z.boolean();
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { JsonSchemaObject } from '../types';
|
||||
|
||||
export const parseDefault = (_jsonSchema: JsonSchemaObject) => {
|
||||
return z.any();
|
||||
};
|
25
packages/@n8n/json-schema-to-zod/src/parsers/parse-enum.ts
Normal file
25
packages/@n8n/json-schema-to-zod/src/parsers/parse-enum.ts
Normal 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,
|
||||
],
|
||||
);
|
||||
};
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
15
packages/@n8n/json-schema-to-zod/src/parsers/parse-not.ts
Normal file
15
packages/@n8n/json-schema-to-zod/src/parsers/parse-not.ts
Normal 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',
|
||||
);
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { JsonSchemaObject } from '../types';
|
||||
|
||||
export const parseNull = (_jsonSchema: JsonSchemaObject & { type: 'null' }) => {
|
||||
return z.null();
|
||||
};
|
|
@ -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();
|
||||
};
|
88
packages/@n8n/json-schema-to-zod/src/parsers/parse-number.ts
Normal file
88
packages/@n8n/json-schema-to-zod/src/parsers/parse-number.ts
Normal 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;
|
||||
};
|
219
packages/@n8n/json-schema-to-zod/src/parsers/parse-object.ts
Normal file
219
packages/@n8n/json-schema-to-zod/src/parsers/parse-object.ts
Normal 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;
|
||||
}
|
41
packages/@n8n/json-schema-to-zod/src/parsers/parse-one-of.ts
Normal file
41
packages/@n8n/json-schema-to-zod/src/parsers/parse-one-of.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
130
packages/@n8n/json-schema-to-zod/src/parsers/parse-schema.ts
Normal file
130
packages/@n8n/json-schema-to-zod/src/parsers/parse-schema.ts
Normal 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;
|
||||
};
|
58
packages/@n8n/json-schema-to-zod/src/parsers/parse-string.ts
Normal file
58
packages/@n8n/json-schema-to-zod/src/parsers/parse-string.ts
Normal 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;
|
||||
};
|
82
packages/@n8n/json-schema-to-zod/src/types.ts
Normal file
82
packages/@n8n/json-schema-to-zod/src/types.ts
Normal 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 }>;
|
||||
};
|
23
packages/@n8n/json-schema-to-zod/src/utils/extend-schema.ts
Normal file
23
packages/@n8n/json-schema-to-zod/src/utils/extend-schema.ts
Normal 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;
|
||||
}
|
3
packages/@n8n/json-schema-to-zod/src/utils/half.ts
Normal file
3
packages/@n8n/json-schema-to-zod/src/utils/half.ts
Normal 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)];
|
||||
};
|
57
packages/@n8n/json-schema-to-zod/src/utils/its.ts
Normal file
57
packages/@n8n/json-schema-to-zod/src/utils/its.ts
Normal 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,
|
||||
},
|
||||
};
|
8
packages/@n8n/json-schema-to-zod/src/utils/omit.ts
Normal file
8
packages/@n8n/json-schema-to-zod/src/utils/omit.ts
Normal 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>;
|
143
packages/@n8n/json-schema-to-zod/test/all.json
Normal file
143
packages/@n8n/json-schema-to-zod/test/all.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
16
packages/@n8n/json-schema-to-zod/test/extend-expect.ts
Normal file
16
packages/@n8n/json-schema-to-zod/test/extend-expect.ts
Normal 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}`,
|
||||
};
|
||||
},
|
||||
});
|
5
packages/@n8n/json-schema-to-zod/test/jest.d.ts
vendored
Normal file
5
packages/@n8n/json-schema-to-zod/test/jest.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
namespace jest {
|
||||
interface Matchers<R, T> {
|
||||
toMatchZod(expected: unknown): T;
|
||||
}
|
||||
}
|
106
packages/@n8n/json-schema-to-zod/test/json-schema-to-zod.test.ts
Normal file
106
packages/@n8n/json-schema-to-zod/test/json-schema-to-zod.test.ts
Normal 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())));
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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());
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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)]));
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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'));
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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());
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
15
packages/@n8n/json-schema-to-zod/test/utils/half.test.ts
Normal file
15
packages/@n8n/json-schema-to-zod/test/utils/half.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
27
packages/@n8n/json-schema-to-zod/test/utils/omit.test.ts
Normal file
27
packages/@n8n/json-schema-to-zod/test/utils/omit.test.ts
Normal 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();
|
||||
});
|
||||
});
|
11
packages/@n8n/json-schema-to-zod/tsconfig.cjs.json
Normal file
11
packages/@n8n/json-schema-to-zod/tsconfig.cjs.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"outDir": "dist/cjs",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
12
packages/@n8n/json-schema-to-zod/tsconfig.esm.json
Normal file
12
packages/@n8n/json-schema-to-zod/tsconfig.esm.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist/esm",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
12
packages/@n8n/json-schema-to-zod/tsconfig.json
Normal file
12
packages/@n8n/json-schema-to-zod/tsconfig.json
Normal 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"]
|
||||
}
|
11
packages/@n8n/json-schema-to-zod/tsconfig.types.json
Normal file
11
packages/@n8n/json-schema-to-zod/tsconfig.types.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "dist/types",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue