Merge remote-tracking branch 'origin/master' into ado-2597-incorrect-expression-when-mapping-from-non-immediate-node-in

This commit is contained in:
Federico Meini 2024-10-30 17:41:25 +01:00
commit a8d81149b3
No known key found for this signature in database
638 changed files with 24742 additions and 4045 deletions

View file

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

View file

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

View file

@ -7,6 +7,7 @@
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"mjmlio.vscode-mjml",
"Vue.volar"
"Vue.volar",
"vitest.explorer"
]
}

View file

@ -1,3 +1,96 @@
# [1.65.0](https://github.com/n8n-io/n8n/compare/n8n@1.64.0...n8n@1.65.0) (2024-10-24)
### Bug Fixes
* **AI Agent Node:** Preserve `intermediateSteps` when using output parser with non-tool agent ([#11363](https://github.com/n8n-io/n8n/issues/11363)) ([e61a853](https://github.com/n8n-io/n8n/commit/e61a8535aa39653b9a87575ea911a65318282167))
* **API:** `PUT /credentials/:id` should move the specified credential, not the first one in the database ([#11365](https://github.com/n8n-io/n8n/issues/11365)) ([e6b2f8e](https://github.com/n8n-io/n8n/commit/e6b2f8e7e6ebbb6e3776a976297d519e99ac6c64))
* **API:** Correct credential schema for response in `POST /credentials` ([#11340](https://github.com/n8n-io/n8n/issues/11340)) ([f495875](https://github.com/n8n-io/n8n/commit/f4958756b4976e0b608b9155dab84564f7e8804e))
* **core:** Account for waiting jobs during shutdown ([#11338](https://github.com/n8n-io/n8n/issues/11338)) ([c863abd](https://github.com/n8n-io/n8n/commit/c863abd08300b53ea898fc4d06aae97dec7afa9b))
* **core:** Add missing primary key to execution annotation tags table ([#11168](https://github.com/n8n-io/n8n/issues/11168)) ([b4b543d](https://github.com/n8n-io/n8n/commit/b4b543d41daa07753eca24ab93bf7445f672361d))
* **core:** Change dedupe value column type from varchar(255) to text ([#11357](https://github.com/n8n-io/n8n/issues/11357)) ([7a71cff](https://github.com/n8n-io/n8n/commit/7a71cff4d75fe4e7282a398b4843428e0161ba8c))
* **core:** Do not debounce webhooks, triggers and pollers activation ([#11306](https://github.com/n8n-io/n8n/issues/11306)) ([64bddf8](https://github.com/n8n-io/n8n/commit/64bddf86536ddd688638a643d24f80c947a12f31))
* **core:** Enforce nodejs version consistently ([#11323](https://github.com/n8n-io/n8n/issues/11323)) ([0fa2e8c](https://github.com/n8n-io/n8n/commit/0fa2e8ca85005362d9043d82469f3c3525f4c4ef))
* **core:** Fix memory issue with empty model response ([#11300](https://github.com/n8n-io/n8n/issues/11300)) ([216b119](https://github.com/n8n-io/n8n/commit/216b119350949de70f15cf2d61f474770803ad7a))
* **core:** Fix race condition when resolving post-execute promise ([#11360](https://github.com/n8n-io/n8n/issues/11360)) ([4f1816e](https://github.com/n8n-io/n8n/commit/4f1816e03db00219bc2e723e3048848aef7f8fe1))
* **core:** Sanitise IdP provided information in SAML test pages ([#11171](https://github.com/n8n-io/n8n/issues/11171)) ([74fc388](https://github.com/n8n-io/n8n/commit/74fc3889b946e8f224e65ef8d3d44125404aa4fc))
* Don't show pin button in input panel when there's binary data ([#11267](https://github.com/n8n-io/n8n/issues/11267)) ([c0b5b92](https://github.com/n8n-io/n8n/commit/c0b5b92f62a2d7ba60492eb27daced268b654fe9))
* **editor:** Add Personal project to main navigation ([#11161](https://github.com/n8n-io/n8n/issues/11161)) ([1f441f9](https://github.com/n8n-io/n8n/commit/1f441f97528f58e905eaf8930577bbcd08debf06))
* **editor:** Fix Cannot read properties of undefined (reading 'finished') ([#11367](https://github.com/n8n-io/n8n/issues/11367)) ([475d72e](https://github.com/n8n-io/n8n/commit/475d72e0bc9e13c6dc56129902f6f89c67547f78))
* **editor:** Fix delete all existing executions ([#11352](https://github.com/n8n-io/n8n/issues/11352)) ([3ec103f](https://github.com/n8n-io/n8n/commit/3ec103f8baaa89e579844947d945f00bec9e498e))
* **editor:** Fix pin data button disappearing after reload ([#11198](https://github.com/n8n-io/n8n/issues/11198)) ([3b2f63e](https://github.com/n8n-io/n8n/commit/3b2f63e248cd0cba04087e2f40e13d670073707d))
* **editor:** Fix RunData non-binary pagination when binary data is present ([#11309](https://github.com/n8n-io/n8n/issues/11309)) ([901888d](https://github.com/n8n-io/n8n/commit/901888d5b1027098653540c72f787f176941f35a))
* **editor:** Fix sorting problem in older browsers that don't support `toSorted` ([#11204](https://github.com/n8n-io/n8n/issues/11204)) ([c728a2f](https://github.com/n8n-io/n8n/commit/c728a2ffe01f510a237979a54897c4680a407800))
* **editor:** Follow-up fixes to projects side menu ([#11327](https://github.com/n8n-io/n8n/issues/11327)) ([4dde772](https://github.com/n8n-io/n8n/commit/4dde772814c55e66efcc9b369ae443328af21b14))
* **editor:** Keep always focus on the first item on the node's search panel ([#11193](https://github.com/n8n-io/n8n/issues/11193)) ([c57cac9](https://github.com/n8n-io/n8n/commit/c57cac9e4d447c3a4240a565f9f2de8aa3b7c513))
* **editor:** Open Community+ enrollment modal only for the instance owner ([#11292](https://github.com/n8n-io/n8n/issues/11292)) ([76724c3](https://github.com/n8n-io/n8n/commit/76724c3be6e001792433045c2b2aac0ef16d4b8a))
* **editor:** Record sessionStarted telemetry event in Setting Store ([#11334](https://github.com/n8n-io/n8n/issues/11334)) ([1b734dd](https://github.com/n8n-io/n8n/commit/1b734dd9f42885594ce02400cfb395a4f5e7e088))
* Ensure NDV params don't get cut off early and scrolled to the top ([#11252](https://github.com/n8n-io/n8n/issues/11252)) ([054fe97](https://github.com/n8n-io/n8n/commit/054fe9745ff6864f9088aa4cd66ed9e7869520d5))
* **HTTP Request Tool Node:** Fix the undefined response issue when authentication is enabled ([#11343](https://github.com/n8n-io/n8n/issues/11343)) ([094ec68](https://github.com/n8n-io/n8n/commit/094ec68d4c00848013aa4eec4ac5efbd2c92afc5))
* Include error in the message in JS task runner sandbox ([#11359](https://github.com/n8n-io/n8n/issues/11359)) ([0708b3a](https://github.com/n8n-io/n8n/commit/0708b3a1f8097af829c92fe106ea6ba375d6c500))
* **Microsoft SQL Node:** Fix execute query to allow for non select query to run ([#11335](https://github.com/n8n-io/n8n/issues/11335)) ([ba158b4](https://github.com/n8n-io/n8n/commit/ba158b4f8533bd3430db8766d4921f75db5c1a11))
* **OpenAI Chat Model Node, Ollama Chat Model Node:** Change default model to a more up-to-date option ([#11293](https://github.com/n8n-io/n8n/issues/11293)) ([0be04c6](https://github.com/n8n-io/n8n/commit/0be04c6348d8c059a96c3d37a6d6cd587bfb97f3))
* **Pinecone Vector Store Node:** Prevent populating of vectors after manually stopping the execution ([#11288](https://github.com/n8n-io/n8n/issues/11288)) ([fbae17d](https://github.com/n8n-io/n8n/commit/fbae17d8fb35a5197fa183e3639bb36762dc73d2))
* **Postgres Node:** Special datetime values cause errors ([#11225](https://github.com/n8n-io/n8n/issues/11225)) ([3c57f46](https://github.com/n8n-io/n8n/commit/3c57f46aaeb968d2974f2dc9790317a6a6fab624))
* Resend invite operation on users list ([#11351](https://github.com/n8n-io/n8n/issues/11351)) ([e4218de](https://github.com/n8n-io/n8n/commit/e4218debd18812fa3aa508339afd3de03c4d69dc))
* **SSH Node:** Cleanup temporary binary files as soon as possible ([#11305](https://github.com/n8n-io/n8n/issues/11305)) ([08a7b5b](https://github.com/n8n-io/n8n/commit/08a7b5b7425663ec6593114921c2e22ab37d039e))
### Features
* Add report bug buttons ([#11304](https://github.com/n8n-io/n8n/issues/11304)) ([296f68f](https://github.com/n8n-io/n8n/commit/296f68f041b93fd32ac7be2b53c2b41d58c2998a))
* **AI Agent Node:** Make tools optional when using OpenAI model with Tools agent ([#11212](https://github.com/n8n-io/n8n/issues/11212)) ([fed7c3e](https://github.com/n8n-io/n8n/commit/fed7c3ec1fb0553adaa9a933f91aabfd54fe83a3))
* **core:** introduce JWT API keys for the public API ([#11005](https://github.com/n8n-io/n8n/issues/11005)) ([679fa4a](https://github.com/n8n-io/n8n/commit/679fa4a10a85fc96e12ca66fe12cdb32368bc12b))
* **core:** Enforce config file permissions on startup ([#11328](https://github.com/n8n-io/n8n/issues/11328)) ([c078a51](https://github.com/n8n-io/n8n/commit/c078a516bec857831cc904ef807d0791b889f3a2))
* **core:** Handle cycles in workflows when partially executing them ([#11187](https://github.com/n8n-io/n8n/issues/11187)) ([321d6de](https://github.com/n8n-io/n8n/commit/321d6deef18806d88d97afef2f2c6f29e739ccb4))
* **editor:** Separate node output execution tooltip from status icon ([#11196](https://github.com/n8n-io/n8n/issues/11196)) ([cd15e95](https://github.com/n8n-io/n8n/commit/cd15e959c7af82a7d8c682e94add2b2640624a70))
* **GitHub Node:** Add workflow resource operations ([#10744](https://github.com/n8n-io/n8n/issues/10744)) ([d309112](https://github.com/n8n-io/n8n/commit/d3091126472faa2c8f270650e54027d19dc56bb6))
* **n8n Form Page Node:** New node ([#10390](https://github.com/n8n-io/n8n/issues/10390)) ([643d66c](https://github.com/n8n-io/n8n/commit/643d66c0ae084a0d93dac652703adc0a32cab8de))
* **n8n Google My Business Node:** New node ([#10504](https://github.com/n8n-io/n8n/issues/10504)) ([bf28fbe](https://github.com/n8n-io/n8n/commit/bf28fbefe5e8ba648cba1555a2d396b75ee32bbb))
* Run `mfa.beforeSetup` hook before enabling MFA ([#11116](https://github.com/n8n-io/n8n/issues/11116)) ([25c1c32](https://github.com/n8n-io/n8n/commit/25c1c3218cf1075ca3abd961236f3b2fbd9d6ba9))
* **Structured Output Parser Node:** Refactor Output Parsers and Improve Error Handling ([#11148](https://github.com/n8n-io/n8n/issues/11148)) ([45274f2](https://github.com/n8n-io/n8n/commit/45274f2e7f081e194e330e1c9e6a5c26fca0b141))
# [1.64.0](https://github.com/n8n-io/n8n/compare/n8n@1.63.0...n8n@1.64.0) (2024-10-16)
### Bug Fixes
* Adjust arrow button colors in dark mode ([#11248](https://github.com/n8n-io/n8n/issues/11248)) ([439132c](https://github.com/n8n-io/n8n/commit/439132c291a812d57702c94eaa12878394ac4c69))
* **core:** Ensure error reporter does not promote `info` to `error` messages ([#11245](https://github.com/n8n-io/n8n/issues/11245)) ([a7fc7fc](https://github.com/n8n-io/n8n/commit/a7fc7fc22997acec86dc94386c95349fd018f4ae))
* **core:** Override executions mode if `regular` during worker startup ([#11250](https://github.com/n8n-io/n8n/issues/11250)) ([c0aa28c](https://github.com/n8n-io/n8n/commit/c0aa28c6cf3f77b04e04663217c9df8e3803ed3f))
* **core:** Wrap nodes for tool use at a suitable time ([#11238](https://github.com/n8n-io/n8n/issues/11238)) ([c2fb881](https://github.com/n8n-io/n8n/commit/c2fb881d61291209802438d95892d052f5c82d43))
* Don't show pinned data tooltip for pinned nodes ([#11249](https://github.com/n8n-io/n8n/issues/11249)) ([c2ad156](https://github.com/n8n-io/n8n/commit/c2ad15646d326a8f71e314d54efe202a5bcdd296))
* **editor:** Bring back the "Forgot password" link on SigninView ([#11216](https://github.com/n8n-io/n8n/issues/11216)) ([4e78c46](https://github.com/n8n-io/n8n/commit/4e78c46a7450c7fc0694369944d4fb446cef2348))
* **editor:** Fix chat crashing when rendering output-parsed content ([#11210](https://github.com/n8n-io/n8n/issues/11210)) ([4aaebfd](https://github.com/n8n-io/n8n/commit/4aaebfd4358f590e98c453ad4e65cc2c9d0f76f8))
* **editor:** Make submit in ChangePasswordView work again ([#11227](https://github.com/n8n-io/n8n/issues/11227)) ([4f27b39](https://github.com/n8n-io/n8n/commit/4f27b39b45b58779d363980241e6e5e11b58f5da))
* Expressions display actual result of evaluating expression inside string ([#11257](https://github.com/n8n-io/n8n/issues/11257)) ([7f5f0a9](https://github.com/n8n-io/n8n/commit/7f5f0a9df3b3fae6e2f9787443ac1cf9415d5932))
* **Google Ads Node:** Update to use v17 api ([#11243](https://github.com/n8n-io/n8n/issues/11243)) ([3d97f02](https://github.com/n8n-io/n8n/commit/3d97f02a8d2b6e5bc7c97c5271bed97417ecacd2))
* **Google Calendar Node:** Fix issue with conference data types not loading ([#11185](https://github.com/n8n-io/n8n/issues/11185)) ([4012758](https://github.com/n8n-io/n8n/commit/401275884e5db0287e4eeffb3c7497dd5e024880))
* **Google Calendar Node:** Mode to add or replace attendees in event update ([#11132](https://github.com/n8n-io/n8n/issues/11132)) ([6c6a8ef](https://github.com/n8n-io/n8n/commit/6c6a8efdea83cf7194304ce089d7b72d8f6c1a9d))
* **HTTP Request Tool Node:** Respond with an error when receive binary response ([#11219](https://github.com/n8n-io/n8n/issues/11219)) ([0d23a7f](https://github.com/n8n-io/n8n/commit/0d23a7fb5ba41545f70c4848d30b90af91b1e7e6))
* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#11242](https://github.com/n8n-io/n8n/issues/11242)) ([b7ee0c4](https://github.com/n8n-io/n8n/commit/b7ee0c4087eae346bc7e5360130d6c812dbe99db))
* **n8n Trigger Node:** Merge with Workflow Trigger node ([#11174](https://github.com/n8n-io/n8n/issues/11174)) ([6ec6b51](https://github.com/n8n-io/n8n/commit/6ec6b5197ae97eb86496effd458fcc0b9b223ef3))
* **OpenAI Node:** Fix tool parameter parsing issue ([#11201](https://github.com/n8n-io/n8n/issues/11201)) ([5a1d81a](https://github.com/n8n-io/n8n/commit/5a1d81ad917fde5cd6a387fe2d4ec6aab6b71349))
* **Set Node:** Fix issue with UI properties not being hidden ([#11263](https://github.com/n8n-io/n8n/issues/11263)) ([1affc27](https://github.com/n8n-io/n8n/commit/1affc27b6bf9a559061a06f92bebe8167d938665))
* **Strava Trigger Node:** Fix issue with webhook not being deleted ([#11226](https://github.com/n8n-io/n8n/issues/11226)) ([566529c](https://github.com/n8n-io/n8n/commit/566529ca1149988a54a58b3c34bbe4d9f1add6db))
### Features
* Add tracking for node errors and update node graph ([#11060](https://github.com/n8n-io/n8n/issues/11060)) ([d3b05f1](https://github.com/n8n-io/n8n/commit/d3b05f1c54e62440666297d8e484ccd22168da48))
* **core:** Dedupe ([#10101](https://github.com/n8n-io/n8n/issues/10101)) ([52dd2c7](https://github.com/n8n-io/n8n/commit/52dd2c76196c6895b47145c2b85a6895ce2874d4))
* **editor:** Send workflow context to assistant store ([#11135](https://github.com/n8n-io/n8n/issues/11135)) ([fade9e4](https://github.com/n8n-io/n8n/commit/fade9e43c84a0ae1fbc80f3ee546a418970e2380))
* **Gong Node:** New node ([#10777](https://github.com/n8n-io/n8n/issues/10777)) ([785b47f](https://github.com/n8n-io/n8n/commit/785b47feb3b83cf36aaed57123f8baca2bbab307))
### Performance Improvements
* **Google Sheets Node:** Don't load whole spreadsheet dataset to determine columns when appending data ([#11235](https://github.com/n8n-io/n8n/issues/11235)) ([26ad091](https://github.com/n8n-io/n8n/commit/26ad091f473bca4e5d3bdc257e0818be02e52db5))
# [1.63.0](https://github.com/n8n-io/n8n/compare/n8n@1.62.1...n8n@1.63.0) (2024-10-09)

View file

@ -68,7 +68,7 @@ If you already have VS Code and Docker installed, you can click [here](https://v
#### Node.js
[Node.js](https://nodejs.org/en/) version 18.10 or newer is required for development purposes.
[Node.js](https://nodejs.org/en/) version 20.15 or newer is required for development purposes.
#### pnpm

View file

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

View file

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

View file

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

View file

@ -1,3 +1,6 @@
import { nanoid } from 'nanoid';
import { simpleWebhookCall, waitForWebhook } from './16-webhook-node.cy';
import {
HTTP_REQUEST_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
@ -7,6 +10,7 @@ import {
} from '../constants';
import { WorkflowPage, NDV } from '../pages';
import { errorToast } from '../pages/notifications';
import { getVisiblePopper } from '../utils';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -212,6 +216,42 @@ describe('Data pinning', () => {
},
);
});
it('should show pinned data tooltip', () => {
const { callEndpoint } = simpleWebhookCall({
method: 'GET',
webhookPath: nanoid(),
executeNow: false,
});
ndv.actions.close();
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
// hide other visible popper on workflow execute button
workflowPage.getters.canvasNodes().eq(0).click();
callEndpoint((response) => {
expect(response.status).to.eq(200);
getVisiblePopper().should('have.length', 1);
getVisiblePopper()
.eq(0)
.should(
'have.text',
'You can pin this output instead of waiting for a test event. Open node to do so.',
);
});
});
it('should not show pinned data tooltip', () => {
cy.createFixtureWorkflow('Pinned_webhook_node.json', 'Test');
workflowPage.actions.executeWorkflow();
// hide other visible popper on workflow execute button
workflowPage.getters.canvasNodes().eq(0).click();
getVisiblePopper().should('have.length', 0);
});
});
function setExpressionOnStringValueInSet(expression: string) {

View file

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

View file

@ -9,7 +9,7 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
const credentialsModal = new CredentialsModal();
const waitForWebhook = 500;
export const waitForWebhook = 500;
interface SimpleWebhookCallOptions {
method: string;
@ -21,7 +21,7 @@ interface SimpleWebhookCallOptions {
authentication?: string;
}
const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
const {
authentication,
method,
@ -65,15 +65,23 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
getVisibleSelect().find('.option-headline').contains(responseData).click();
}
const callEndpoint = (cb: (response: Cypress.Response<unknown>) => void) => {
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(cb);
};
if (executeNow) {
ndv.actions.execute();
cy.wait(waitForWebhook);
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
callEndpoint((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
}
return {
callEndpoint,
};
};
describe('Webhook Trigger node', () => {

View file

@ -226,6 +226,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
.should('have.length', 1)
.click();
credentialsModal.getters.saveButton().click();
credentialsModal.getters.saveButton().should('have.text', 'Saved');
credentialsModal.actions.close();
projects.getProjectTabWorkflows().click();
@ -252,12 +253,13 @@ describe('Sharing', { disableAutoLogin: true }, () => {
credentialsModal.getters.usersSelect().click();
getVisibleSelect().find('li').should('have.length', 4).first().click();
credentialsModal.getters.saveButton().click();
credentialsModal.getters.saveButton().should('have.text', 'Saved');
credentialsModal.actions.close();
credentialsPage.getters
.credentialCards()
.should('have.length', 2)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('have.length', 1);
});
});

View file

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

View file

@ -15,7 +15,7 @@ import {
TRELLO_NODE_NAME,
} from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { successToast } from '../pages/notifications';
import { errorToast, successToast } from '../pages/notifications';
import { getVisibleSelect } from '../utils';
const credentialsPage = new CredentialsPage();
@ -278,4 +278,25 @@ describe('Credentials', () => {
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
nodeDetailsView.getters.copyInput().should('not.exist');
});
it('ADO-2583 should show notifications above credential modal overlay', () => {
// check error notifications because they are sticky
cy.intercept('POST', '/rest/credentials', { forceNetworkError: true });
credentialsPage.getters.createCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account');
credentialsModal.getters.saveButton().click({ force: true });
errorToast().should('have.length', 1);
errorToast().should('be.visible');
errorToast().should('have.css', 'z-index', '2100');
cy.get('.el-overlay').should('have.css', 'z-index', '2001');
});
});

View file

@ -0,0 +1,21 @@
import { WEBHOOK_NODE_NAME } from '../constants';
import { NDV, WorkflowPage } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2270 Save button resets on webhook node open', () => {
it('should not reset the save button if webhook node is opened and closed', () => {
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(WEBHOOK_NODE_NAME);
workflowPage.getters.saveButton().click();
workflowPage.actions.openNode(WEBHOOK_NODE_NAME);
ndv.actions.close();
cy.ifCanvasVersion(
() => cy.getByTestId('workflow-save-button').should('not.contain', 'Saved'),
() => cy.getByTestId('workflow-save-button').should('contain', 'Saved'),
);
});
});

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import {
NDV,
MainSidebar,
} from '../pages';
import { clearNotifications } from '../pages/notifications';
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
const workflowsPage = new WorkflowsPage();
@ -448,38 +449,48 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Home project');
clearNotifications();
projects.getHomeButton().click();
projects.getProjectTabCredentials().should('be.visible').click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Home project');
clearNotifications();
// Create a project and add a credential and a workflow to it
projects.createProject('Project 1');
clearNotifications();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 1');
clearNotifications();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 1');
clearNotifications();
// Create another project and add a credential and a workflow to it
projects.createProject('Project 2');
clearNotifications();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 2');
clearNotifications();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 2');
clearNotifications();
// Move the workflow owned by me from Home to Project 1
// Move the workflow Personal from Home to Project 1
projects.getHomeButton().click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('exist');
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
@ -496,11 +507,12 @@ describe('Projects', { disableAutoLogin: true }, () => {
.filter(':contains("Project 1")')
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
clearNotifications();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('not.exist');
// Move the workflow from Project 1 to Project 2
@ -527,6 +539,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
clearNotifications();
projects
.getResourceMoveModal()
@ -566,10 +579,11 @@ describe('Projects', { disableAutoLogin: true }, () => {
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
clearNotifications();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('have.length', 1);
// Move the credential from Project 1 to Project 2
@ -591,7 +605,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
.filter(':contains("Project 2")')
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
clearNotifications();
credentialsPage.getters.credentialCards().should('not.have.length');
// Move the credential from Project 2 to admin user
@ -637,10 +651,12 @@ describe('Projects', { disableAutoLogin: true }, () => {
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
clearNotifications();
credentialsPage.getters
.credentialCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('have.length', 2);
// Move the credential from admin user back to its original project (Project 1)
@ -699,7 +715,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('exist');
workflowsPage.getters.workflowCardActions('My workflow').click();
workflowsPage.getters.workflowMoveButton().click();
@ -720,7 +736,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('not.exist');
//Log out with instance owner and log in with the member user

View file

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

View file

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

View file

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

View file

@ -0,0 +1,39 @@
{
"nodes": [
{
"parameters": {
"path": "FwrbSiaua2Xmvn6-Z-7CQ",
"options": {}
},
"id": "8fcc7e5f-2cef-4938-9564-eea504c20aa0",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
360,
220
],
"webhookId": "9c778f2a-e882-46ed-a0e4-c8e2f76ccd65"
}
],
"connections": {},
"pinData": {
"Webhook": [
{
"headers": {
"connection": "keep-alive",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"accept": "*/*",
"cookie": "n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNiM2FhOTE5LWRhZDgtNDE5MS1hZWZiLTlhZDIwZTZkMjJjNiIsImhhc2giOiJ1ZVAxR1F3U2paIiwiaWF0IjoxNzI4OTE1NTQyLCJleHAiOjE3Mjk1MjAzNDJ9.fV02gpUnSiUoMxHwfB0npBjcjct7Mv9vGfj-jRTT3-I",
"host": "localhost:5678",
"accept-encoding": "gzip, deflate"
},
"params": {},
"query": {},
"body": {},
"webhookUrl": "http://localhost:5678/webhook-test/FwrbSiaua2Xmvn6-Z-7CQ",
"executionMode": "test"
}
]
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,6 +31,30 @@ WORKDIR /home/node
COPY --from=builder /compiled /usr/local/lib/node_modules/n8n
COPY docker/images/n8n/docker-entrypoint.sh /
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.1.1
ENV N8N_RUNNERS_MODE=internal_launcher \
N8N_RUNNERS_LAUNCHER_PATH=/usr/local/bin/task-runner-launcher
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
# First, download, verify, then extract the launcher binary
# Second, chmod with 4555 to allow the use of setuid
# Third, create a new user and group to execute the Task Runners under
RUN \
if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="x86_64"; \
elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="aarch64"; fi; \
mkdir /launcher-temp && \
cd /launcher-temp && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
sha256sum -c task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
unzip -d $(dirname ${N8N_RUNNERS_LAUNCHER_PATH}) task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip task-runner-launcher && \
cd - && \
rm -r /launcher-temp && \
chmod 4555 ${N8N_RUNNERS_LAUNCHER_PATH} && \
addgroup -g 2000 task-runner && \
adduser -D -u 2000 -g "Task Runner User" -G task-runner task-runner
RUN \
cd /usr/local/lib/node_modules/n8n && \
npm rebuild sqlite3 && \

View file

@ -22,6 +22,30 @@ RUN set -eux; \
find /usr/local/lib/node_modules/n8n -type f -name "*.ts" -o -name "*.js.map" -o -name "*.vue" | xargs rm -f && \
rm -rf /root/.npm
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.1.1
ENV N8N_RUNNERS_MODE=internal_launcher \
N8N_RUNNERS_LAUNCHER_PATH=/usr/local/bin/task-runner-launcher
COPY n8n-task-runners.json /etc/n8n-task-runners.json
# First, download, verify, then extract the launcher binary
# Second, chmod with 4555 to allow the use of setuid
# Third, create a new user and group to execute the Task Runners under
RUN \
if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="x86_64"; \
elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="aarch64"; fi; \
mkdir /launcher-temp && \
cd /launcher-temp && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
sha256sum -c task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
unzip -d $(dirname ${N8N_RUNNERS_LAUNCHER_PATH}) task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip task-runner-launcher && \
cd - && \
rm -r /launcher-temp && \
chmod 4555 ${N8N_RUNNERS_LAUNCHER_PATH} && \
addgroup -g 2000 task-runner && \
adduser -D -u 2000 -g "Task Runner User" -G task-runner task-runner
COPY docker-entrypoint.sh /
RUN \

View file

@ -0,0 +1,22 @@
{
"task-runners": [
{
"runner-type": "javascript",
"workdir": "/home/task-runner",
"command": "/usr/local/bin/node",
"args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"],
"allowed-env": [
"PATH",
"N8N_RUNNERS_GRANT_TOKEN",
"N8N_RUNNERS_N8N_URI",
"N8N_RUNNERS_MAX_PAYLOAD",
"N8N_RUNNERS_MAX_CONCURRENCY",
"NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS"
],
"uid": 2000,
"gid": 2000
}
]
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.63.0",
"version": "1.65.0",
"private": true,
"engines": {
"node": ">=20.15",
@ -43,6 +43,7 @@
"@biomejs/biome": "^1.9.0",
"@n8n_io/eslint-config": "workspace:*",
"@types/jest": "^29.5.3",
"@types/node": "*",
"@types/supertest": "^6.0.2",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
@ -57,8 +58,8 @@
"run-script-os": "^1.0.7",
"supertest": "^7.0.0",
"ts-jest": "^29.1.1",
"tsc-alias": "^1.8.7",
"tsc-watch": "^6.0.4",
"tsc-alias": "^1.8.10",
"tsc-watch": "^6.2.0",
"turbo": "2.1.2",
"typescript": "*",
"zx": "^8.1.4"
@ -87,7 +88,8 @@
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch"
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch",
"@langchain/core@0.3.3": "patches/@langchain__core@0.3.3.patch"
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.4.0",
"version": "0.5.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -21,6 +21,7 @@
"dist/**/*"
],
"devDependencies": {
"@n8n/config": "workspace:*",
"n8n-workflow": "workspace:*"
},
"dependencies": {

View file

@ -1,3 +1,4 @@
import type { FrontendBetaFeatures } from '@n8n/config';
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
export interface IVersionNotificationSettings {
@ -169,4 +170,5 @@ export interface FrontendSettings {
security: {
blockFileAccessToN8nFiles: boolean;
};
betaFeatures: FrontendBetaFeatures[];
}

View file

@ -1,6 +1,6 @@
# n8n benchmarking tool
Tool for executing benchmarks against an n8n instance. The tool consists of these components:
Tool for executing benchmarks against an n8n instance.
## Directory structure
@ -12,6 +12,39 @@ packages/@n8n/benchmark
├── scripts Orchestration scripts
```
## Benchmarking an existing n8n instance
The easiest way to run the existing benchmark scenarios is to use the benchmark docker image:
```sh
docker pull ghcr.io/n8n-io/n8n-benchmark:latest
# Print the help to list all available flags
docker run ghcr.io/n8n-io/n8n-benchmark:latest run --help
# Run all available benchmark scenarios for 1 minute with 5 concurrent requests
docker run ghcr.io/n8n-io/n8n-benchmark:latest run \
--n8nBaseUrl=https://instance.url \
--n8nUserEmail=InstanceOwner@email.com \
--n8nUserPassword=InstanceOwnerPassword \
--vus=5 \
--duration=1m \
--scenarioFilter SingleWebhook
```
### Using custom scenarios with the Docker image
It is also possible to create your own [benchmark scenarios](#benchmark-scenarios) and load them using the `--testScenariosPath` flag:
```sh
# Assuming your scenarios are located in `./scenarios`, mount them into `/scenarios` in the container
docker run -v ./scenarios:/scenarios ghcr.io/n8n-io/n8n-benchmark:latest run \
--n8nBaseUrl=https://instance.url \
--n8nUserEmail=InstanceOwner@email.com \
--n8nUserPassword=InstanceOwnerPassword \
--vus=5 \
--duration=1m \
--testScenariosPath=/scenarios
```
## Running the entire benchmark suite
The benchmark suite consists of [benchmark scenarios](#benchmark-scenarios) and different [n8n setups](#n8n-setups).

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-benchmark",
"version": "1.7.0",
"version": "1.8.0",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {
@ -17,7 +17,7 @@
"benchmark-locally": "pnpm benchmark --env local",
"provision-cloud-env": "zx scripts/provision-cloud-env.mjs",
"destroy-cloud-env": "zx scripts/destroy-cloud-env.mjs",
"watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\""
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\""
},
"engines": {
"node": ">=20.10"
@ -41,10 +41,7 @@
},
"devDependencies": {
"@types/convict": "^6.1.1",
"@types/k6": "^0.52.0",
"@types/node": "^20.14.8",
"tsc-alias": "^1.8.7",
"typescript": "^5.5.2"
"@types/k6": "^0.52.0"
},
"bin": {
"n8n-benchmark": "./bin/n8n-benchmark"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,10 @@ export default class RunCommand extends Command {
static flags = {
testScenariosPath,
scenarioFilter: Flags.string({
char: 'f',
description: 'Filter scenarios by name',
}),
scenarioNamePrefix: Flags.string({
description: 'Prefix for the scenario name',
default: 'Unnamed',
@ -95,7 +99,7 @@ export default class RunCommand extends Command {
flags.scenarioNamePrefix,
);
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath);
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath, flags.scenarioFilter);
await scenarioRunner.runManyScenarios(allScenarios);
}

View file

@ -8,7 +8,7 @@ export class ScenarioLoader {
/**
* Loads all scenarios from the given path
*/
loadAll(pathToScenarios: string): Scenario[] {
loadAll(pathToScenarios: string, filter?: string): Scenario[] {
pathToScenarios = path.resolve(pathToScenarios);
const scenarioFolders = fs
.readdirSync(pathToScenarios, { withFileTypes: true })
@ -18,6 +18,9 @@ export class ScenarioLoader {
const scenarios: Scenario[] = [];
for (const folder of scenarioFolders) {
if (filter && !folder.toLowerCase().includes(filter.toLowerCase())) {
continue;
}
const scenarioPath = path.join(pathToScenarios, folder);
const manifestFileName = `${folder}.manifest.json`;
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);

View file

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

View file

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

View file

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

View file

@ -0,0 +1,11 @@
import { Config, Env } from '../decorators';
import { StringArray } from '../utils';
export type FrontendBetaFeatures = 'canvas_v2';
@Config
export class FrontendConfig {
/** Which UI experiments to enable. Separate multiple values with a comma `,` */
@Env('N8N_UI_BETA_FEATURES')
betaFeatures: StringArray<FrontendBetaFeatures> = [];
}

View file

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

View file

@ -0,0 +1,28 @@
import { Config, Env } from '../decorators';
@Config
export class LicenseConfig {
/** License server URL to retrieve license. */
@Env('N8N_LICENSE_SERVER_URL')
serverUrl: string = 'https://license.n8n.io/v1';
/** Whether autorenewal for licenses is enabled. */
@Env('N8N_LICENSE_AUTO_RENEW_ENABLED')
autoRenewalEnabled: boolean = true;
/** How long (in seconds) before expiry a license should be autorenewed. */
@Env('N8N_LICENSE_AUTO_RENEW_OFFSET')
autoRenewOffset: number = 60 * 60 * 72; // 72 hours
/** Activation key to initialize license. */
@Env('N8N_LICENSE_ACTIVATION_KEY')
activationKey: string = '';
/** Tenant ID used by the license manager SDK, e.g. for self-hosted, sandbox, embed, cloud. */
@Env('N8N_LICENSE_TENANT_ID')
tenantId: number = 1;
/** Ephemeral license certificate. See: https://github.com/n8n-io/license-management?tab=readme-ov-file#concept-ephemeral-entitlements */
@Env('N8N_LICENSE_CERT')
cert: string = '';
}

View file

@ -1,14 +1,18 @@
import { Config, Env, Nested } from '../decorators';
import { StringArray } from '../utils';
/**
* Scopes (areas of functionality) to filter logs by.
*
* `executions` -> execution lifecycle
* `license` -> license SDK
* `scaling` -> scaling mode
*/
export const LOG_SCOPES = ['executions', 'license', 'scaling'] as const;
/** Scopes (areas of functionality) to filter logs by. */
export const LOG_SCOPES = [
'concurrency',
'external-secrets',
'license',
'multi-main-setup',
'pubsub',
'redis',
'scaling',
'waiting-executions',
'task-runner',
] as const;
export type LogScope = (typeof LOG_SCOPES)[number];
@ -59,14 +63,20 @@ export class LoggingConfig {
/**
* Scopes to filter logs by. Nothing is filtered by default.
*
* Currently supported log scopes:
* - `executions`
* Supported log scopes:
*
* - `concurrency`
* - `external-secrets`
* - `license`
* - `multi-main-setup`
* - `pubsub`
* - `redis`
* - `scaling`
* - `waiting-executions`
*
* @example
* `N8N_LOG_SCOPES=license`
* `N8N_LOG_SCOPES=license,executions`
* `N8N_LOG_SCOPES=license,waiting-executions`
*/
@Env('N8N_LOG_SCOPES')
scopes: StringArray<LogScope> = [];

View file

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

View file

@ -1,11 +1,23 @@
import { Config, Env } from '../decorators';
/**
* Whether to enable task runners and how to run them
* - internal_childprocess: Task runners are run as a child process and launched by n8n
* - internal_launcher: Task runners are run as a child process and launched by n8n using a separate launch program
* - external: Task runners are run as a separate program not launched by n8n
*/
export type TaskRunnerMode = 'internal_childprocess' | 'internal_launcher' | 'external';
@Config
export class TaskRunnersConfig {
// Defaults to true for now
@Env('N8N_RUNNERS_DISABLED')
disabled: boolean = true;
// Defaults to true for now
@Env('N8N_RUNNERS_MODE')
mode: TaskRunnerMode = 'internal_childprocess';
@Env('N8N_RUNNERS_PATH')
path: string = '/runners';
@ -18,5 +30,24 @@ export class TaskRunnersConfig {
/** IP address task runners server should listen on */
@Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS')
listen_address: string = '127.0.0.1';
listenAddress: string = '127.0.0.1';
/** Maximum size of a payload sent to the runner in bytes, Default 1G */
@Env('N8N_RUNNERS_MAX_PAYLOAD')
maxPayload: number = 1024 * 1024 * 1024;
@Env('N8N_RUNNERS_LAUNCHER_PATH')
launcherPath: string = '';
/** Which task runner to launch from the config */
@Env('N8N_RUNNERS_LAUNCHER_RUNNER')
launcherRunner: string = 'javascript';
/** The --max-old-space-size option to use for the runner (in MB). Default means node.js will determine it based on the available memory. */
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
maxOldSpaceSize: string = '';
/** How many concurrent tasks can a runner execute at a time */
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
maxConcurrency: number = 5;
}

View file

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

View file

@ -0,0 +1,27 @@
import { Config, Env } from '../decorators';
@Config
export class SecurityConfig {
/**
* Which directories to limit n8n's access to. Separate multiple dirs with semicolon `;`.
*
* @example N8N_RESTRICT_FILE_ACCESS_TO=/home/user/.n8n;/home/user/n8n-data
*/
@Env('N8N_RESTRICT_FILE_ACCESS_TO')
restrictFileAccessTo: string = '';
/**
* Whether to block access to all files at:
* - the ".n8n" directory,
* - the static cache dir at ~/.cache/n8n/public, and
* - user-defined config files.
*/
@Env('N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES')
blockFileAccessToN8nFiles: boolean = true;
/**
* In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed.
*/
@Env('N8N_SECURITY_AUDIT_DAYS_ABANDONED_WORKFLOW')
daysAbandonedWorkflow: number = 90;
}

View file

@ -5,12 +5,15 @@ 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 { LicenseConfig } from './configs/license.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';
export { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SecurityConfig } from './configs/security.config';
import { SentryConfig } from './configs/sentry.config';
import { TemplatesConfig } from './configs/templates.config';
import { UserManagementConfig } from './configs/user-management.config';
@ -18,6 +21,10 @@ import { VersionNotificationsConfig } from './configs/version-notifications.conf
import { WorkflowsConfig } from './configs/workflows.config';
import { Config, Env, Nested } from './decorators';
export { Config, Env, Nested } from './decorators';
export { TaskRunnersConfig } from './configs/runners.config';
export { SecurityConfig } from './configs/security.config';
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
export { LOG_SCOPES } from './configs/logging.config';
export type { LogScope } from './configs/logging.config';
@ -93,4 +100,16 @@ export class GlobalConfig {
@Nested
taskRunners: TaskRunnersConfig;
@Nested
multiMainSetup: MultiMainSetupConfig;
@Nested
generic: GenericConfig;
@Nested
license: LicenseConfig;
@Nested
security: SecurityConfig;
}

View file

@ -211,7 +211,6 @@ describe('GlobalConfig', () => {
clusterNodes: '',
tls: false,
},
queueRecoveryInterval: 60,
gracefulShutdownTimeout: 30,
prefix: 'bull',
settings: {
@ -224,10 +223,16 @@ describe('GlobalConfig', () => {
},
taskRunners: {
disabled: true,
mode: 'internal_childprocess',
path: '/runners',
authToken: '',
listen_address: '127.0.0.1',
listenAddress: '127.0.0.1',
maxPayload: 1024 * 1024 * 1024,
port: 5679,
launcherPath: '',
launcherRunner: 'javascript',
maxOldSpaceSize: '',
maxConcurrency: 5,
},
sentry: {
backendDsn: '',
@ -243,6 +248,29 @@ describe('GlobalConfig', () => {
},
scopes: [],
},
multiMainSetup: {
enabled: false,
ttl: 10,
interval: 3,
},
generic: {
timezone: 'America/New_York',
releaseChannel: 'dev',
gracefulShutdownTimeout: 30,
},
license: {
serverUrl: 'https://license.n8n.io/v1',
autoRenewalEnabled: true,
autoRenewOffset: 60 * 60 * 72,
activationKey: '',
tenantId: 1,
cert: '',
},
security: {
restrictFileAccessTo: '',
blockFileAccessToN8nFiles: true,
daysAbandonedWorkflow: 90,
},
};
it('should use all default values when no env variables are defined', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,68 @@
{
"name": "@n8n/json-schema-to-zod",
"version": "1.1.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",
"zod": "catalog:"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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