diff --git a/.github/workflows/check-pr-title.yml b/.github/workflows/check-pr-title.yml index 0b9a0b614ab..94357cc499e 100644 --- a/.github/workflows/check-pr-title.yml +++ b/.github/workflows/check-pr-title.yml @@ -15,6 +15,6 @@ jobs: timeout-minutes: 5 steps: - name: Validate PR title - uses: n8n-io/validate-n8n-pull-request-title@c97ff722ac14ee0bda73766473bba764445db805 # v2.2.0 + uses: n8n-io/validate-n8n-pull-request-title@c3b6fd06bda12eebd57a592c0cf3b747d5b73569 # v2.4.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci-evals.yml b/.github/workflows/ci-evals.yml index ab7df6dc3fa..9992dda073f 100644 --- a/.github/workflows/ci-evals.yml +++ b/.github/workflows/ci-evals.yml @@ -30,4 +30,4 @@ jobs: - name: Run Evaluations working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations run: | - pnpm eval:langsmith + pnpm eval:langsmith --repetitions 3 diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 3f2dcefaab7..b7bad9321ba 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -29,7 +29,7 @@ jobs: uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0 - name: Install Python - run: uv python install 3.13 + run: uv python install 3.14 - name: Install project dependencies run: just sync-all diff --git a/.github/workflows/docker-base-image.yml b/.github/workflows/docker-base-image.yml index 4c36967eab5..2446ce6b4f1 100644 --- a/.github/workflows/docker-base-image.yml +++ b/.github/workflows/docker-base-image.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node_version: ['20', '22', '24'] + node_version: ['20', '22.18.0', '24'] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 6821b8dd689..dc8c646c31c 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -9,6 +9,7 @@ name: 'Docker: Build and Push' env: NODE_OPTIONS: '--max-old-space-size=7168' + NODE_VERSION: '22.18.0' on: schedule: @@ -352,7 +353,7 @@ jobs: context: . file: ./docker/images/n8n/Dockerfile build-args: | - NODE_VERSION=22 + NODE_VERSION=${{ env.NODE_VERSION }} N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }} N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }} platforms: ${{ matrix.docker_platform }} @@ -367,8 +368,7 @@ jobs: context: . file: ./docker/images/runners/Dockerfile build-args: | - NODE_VERSION=22.19 - PYTHON_VERSION=3.13 + NODE_VERSION=${{ env.NODE_VERSION }} N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }} N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }} platforms: ${{ matrix.docker_platform }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f6bee468c..ab2de2d6526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,67 @@ +# [1.116.0](https://github.com/n8n-io/n8n/compare/n8n@1.115.0...n8n@1.116.0) (2025-10-13) + + +### Bug Fixes + +* **ai-builder:** Fix loading of Data Table nodes for AI Builder ([#20546](https://github.com/n8n-io/n8n/issues/20546)) ([c21968d](https://github.com/n8n-io/n8n/commit/c21968db3d05d706cba199fb101f04a637132271)) +* **core:** Block introspection attributes unconditionally ([#20641](https://github.com/n8n-io/n8n/issues/20641)) ([7ae88f8](https://github.com/n8n-io/n8n/commit/7ae88f836c13d81118231fe80e8329a81bd29e26)) +* **core:** Fix broker websocket connection closure on runner heartbeat failure ([#20584](https://github.com/n8n-io/n8n/issues/20584)) ([892cc82](https://github.com/n8n-io/n8n/commit/892cc8254dcab13290d88baacc3ad0a3f1224645)) +* **core:** Fix N8N_ENCRYPTION_KEY_FILE environment variable not working ([#20230](https://github.com/n8n-io/n8n/issues/20230)) ([502dd71](https://github.com/n8n-io/n8n/commit/502dd71811df9b9a466584418f41668345791ecc)) +* **core:** Fix worker setup completion ([#20495](https://github.com/n8n-io/n8n/issues/20495)) ([8f042a6](https://github.com/n8n-io/n8n/commit/8f042a6c133c9005014f1448470a886b83a76d47)) +* **core:** Make sure scopes are deleted after rename ([#20498](https://github.com/n8n-io/n8n/issues/20498)) ([a998e1d](https://github.com/n8n-io/n8n/commit/a998e1d025cf29fd92e229429f00c6c7c773c2dc)) +* **core:** Only resume waiting parent workflows ([#20342](https://github.com/n8n-io/n8n/issues/20342)) ([bebccfd](https://github.com/n8n-io/n8n/commit/bebccfdb9388667989d293c205d9620ec6098121)) +* **core:** Prevent re-imported scheduled workflow to execute twice ([#20438](https://github.com/n8n-io/n8n/issues/20438)) ([8f7f480](https://github.com/n8n-io/n8n/commit/8f7f48043b28fc41254b6bc71eb13fe025a0eb37)) +* **core:** Prevent subscript access to blocked attributes ([#20710](https://github.com/n8n-io/n8n/issues/20710)) ([0026b6b](https://github.com/n8n-io/n8n/commit/0026b6b6b0de5b84fc6ed9b8988d7e9f82a4d23f)) +* **core:** Remove logs skipping flag from native Python runner ([#20441](https://github.com/n8n-io/n8n/issues/20441)) ([123a742](https://github.com/n8n-io/n8n/commit/123a7426852b1a3a7a575e51e0c1207764d0ca3f)) +* **core:** Retain source overwrite in paired items in tool executions ([#20629](https://github.com/n8n-io/n8n/issues/20629)) ([6f368c3](https://github.com/n8n-io/n8n/commit/6f368c326d219f23cd508c2cf295a804988d15ec)) +* **core:** Return insights when only one day is selected ([#20543](https://github.com/n8n-io/n8n/issues/20543)) ([dc72c23](https://github.com/n8n-io/n8n/commit/dc72c23d6ad67d09a48ffdec3de3fda565fccf8e)) +* **core:** Solve intermittent typeorm-related build errors for `QueryDeepPartialEntity` ([#20556](https://github.com/n8n-io/n8n/issues/20556)) ([dfb1851](https://github.com/n8n-io/n8n/commit/dfb185151647e79f94d3ba11387d6d6c8c3a11b3)) +* **core:** Tighten Sentry error filtering in native Python runner ([#20500](https://github.com/n8n-io/n8n/issues/20500)) ([bcdbada](https://github.com/n8n-io/n8n/commit/bcdbada74b25e2b5c8617bee0f7b62a6c4c97c2e)) +* **editor:** Compact large ITaskDataConnections before sending to AI Builder ([#20545](https://github.com/n8n-io/n8n/issues/20545)) ([e58480f](https://github.com/n8n-io/n8n/commit/e58480f901126d9a62fdef5153808329ea2db7f4)) +* **editor:** Fix data table add row missing border ([#20514](https://github.com/n8n-io/n8n/issues/20514)) ([799634f](https://github.com/n8n-io/n8n/commit/799634fa45bf792720c12f10e73226d662298014)) +* **editor:** Fix inputs when extracting sub-workflows with Split Out nodes ([#19923](https://github.com/n8n-io/n8n/issues/19923)) ([fa64bf1](https://github.com/n8n-io/n8n/commit/fa64bf1ef35773a665fe4a7ffb80d455350f1eab)) +* **editor:** Input/output panel in log view shows "N of N item(s)" when nothing matched ([#20224](https://github.com/n8n-io/n8n/issues/20224)) ([9b46bf6](https://github.com/n8n-io/n8n/commit/9b46bf65f38cf16896f98360aa23dd43e5bd2ac6)) +* **editor:** Keep source control and user area fixed to bottom of sidebar ([#20530](https://github.com/n8n-io/n8n/issues/20530)) ([0f28b3f](https://github.com/n8n-io/n8n/commit/0f28b3f75676ac30fddc219c2882b747308eac80)) +* **editor:** New NDV design tweaks ([#19903](https://github.com/n8n-io/n8n/issues/19903)) ([ca84331](https://github.com/n8n-io/n8n/commit/ca84331761b00ccaa31ae82cc562cb9f43493cfc)) +* **editor:** Rename property names in event ([#20537](https://github.com/n8n-io/n8n/issues/20537)) ([32573ca](https://github.com/n8n-io/n8n/commit/32573caae136cd6d9d0e3b403509869141f16925)) +* **editor:** Set warning limit to 80% of max limit for data tables ([#20613](https://github.com/n8n-io/n8n/issues/20613)) ([fb94b77](https://github.com/n8n-io/n8n/commit/fb94b779c8de6ead9d6d8d2075a71b1fbb50091f)) +* **Extract from File Node:** Fix xlsx data read when readAsString is true ([#20565](https://github.com/n8n-io/n8n/issues/20565)) ([1a8b6e1](https://github.com/n8n-io/n8n/commit/1a8b6e190194c3159ab90af39a1e02658e3d4dd4)) +* **Github Node:** Fix GitHub node no longer shows repo owner for Get Issues operation ([#20580](https://github.com/n8n-io/n8n/issues/20580)) ([3d74c3e](https://github.com/n8n-io/n8n/commit/3d74c3ee9ec7f0c812db6b2c39e3e23eb2a14b34)) +* **Google Workspace Admin Node:** Rename userId to userKey ([#15940](https://github.com/n8n-io/n8n/issues/15940)) ([65b1df9](https://github.com/n8n-io/n8n/commit/65b1df921063ab3781152fea067062b9b0e0e402)) +* **HTTP Request Node:** Body must be stringified while using AWS credentials ([#20526](https://github.com/n8n-io/n8n/issues/20526)) ([c28ac73](https://github.com/n8n-io/n8n/commit/c28ac73e66b7d72b61b6a1b3aab530fdd355cfe4)) +* Invalid secret expression value for AWS secret keys containing / ([#20433](https://github.com/n8n-io/n8n/issues/20433)) ([f46b5e1](https://github.com/n8n-io/n8n/commit/f46b5e16406b31fbebfa85e0168f45acb3a291af)) +* **Microsoft Graph Security Node:** Add missing offline_access scope to credentials ([#20532](https://github.com/n8n-io/n8n/issues/20532)) ([8dd7c40](https://github.com/n8n-io/n8n/commit/8dd7c402918d8382e9efd32ee11feed8852607b8)) +* Pin node version in Docker base image ([#20634](https://github.com/n8n-io/n8n/issues/20634)) ([4d80c2e](https://github.com/n8n-io/n8n/commit/4d80c2e898831539623a9276fc49baf77e881024)) +* Prevent runtime import via `__builtins__` dict in native Python runner ([#20628](https://github.com/n8n-io/n8n/issues/20628)) ([09c8559](https://github.com/n8n-io/n8n/commit/09c8559c2c9862420d5c405a21933156b879b1ed)) +* **Slack Node:** Add :history scopes to support the 'history' operation in Slack node ([#20523](https://github.com/n8n-io/n8n/issues/20523)) ([88b8719](https://github.com/n8n-io/n8n/commit/88b87191e5677a417fe2ce21a335ea8f01c0d06f)) +* **Supabase Node:** Fix issue with execute function was called with incorrect parameters when accessing schema if set by expression ([#20507](https://github.com/n8n-io/n8n/issues/20507)) ([b868284](https://github.com/n8n-io/n8n/commit/b868284851f1900e6154541129519048002e9fc5)) +* Update libxml2 ([#20635](https://github.com/n8n-io/n8n/issues/20635)) ([2ac03d0](https://github.com/n8n-io/n8n/commit/2ac03d069133bf69cd63826024e14332fd0bc059)) +* Update path.join -> safeJoinPath for compression utils ([#20461](https://github.com/n8n-io/n8n/issues/20461)) ([ad11e77](https://github.com/n8n-io/n8n/commit/ad11e77b42cd805962a2a21bf078569e986368fa)) + + +### Features + +* Add docs to `@n8n/eslint-plugin-community-nodes` ([#20266](https://github.com/n8n-io/n8n/issues/20266)) ([6cb36b5](https://github.com/n8n-io/n8n/commit/6cb36b5194ec684ab515ad4b6d78d305ace2b07e)) +* Add status check for project json files in git folder ([#20369](https://github.com/n8n-io/n8n/issues/20369)) ([2f38db8](https://github.com/n8n-io/n8n/commit/2f38db86b50d0ff4b2b4cd302d69982601adb200)) +* Add strict mode and cloud lint rules to @n8n/node-cli ([#20142](https://github.com/n8n-io/n8n/issues/20142)) ([b1baca5](https://github.com/n8n-io/n8n/commit/b1baca5c6c22115a5769e7a34135a30158d1499d)) +* Add support for displayOptions in INodePropertyOptions ([#20184](https://github.com/n8n-io/n8n/issues/20184)) ([fd50563](https://github.com/n8n-io/n8n/commit/fd50563591a77b81677660cd9b3f7d678e6fa04f)) +* **ai-builder, editor:** Flag AI builder placeholder parameters and render them on front-end ([#20494](https://github.com/n8n-io/n8n/issues/20494)) ([95d0c45](https://github.com/n8n-io/n8n/commit/95d0c45771fbc9486f7372b7658cae303f5c2329)) +* **API:** Add project and projectId fields to get and update a variable project ([#20544](https://github.com/n8n-io/n8n/issues/20544)) ([5bddbed](https://github.com/n8n-io/n8n/commit/5bddbedf2eb5b6363e24e20dccfea267b80001fb)) +* **core:** Telemetry for data tables storage limit reached ([#20485](https://github.com/n8n-io/n8n/issues/20485)) ([52ad94f](https://github.com/n8n-io/n8n/commit/52ad94f54cef6fb16d9cff6fb13f935b091c8ac9)) +* **core:** Track package_version of community nodes ([#20428](https://github.com/n8n-io/n8n/issues/20428)) ([0da3e14](https://github.com/n8n-io/n8n/commit/0da3e14a521535f8b4c01c4be7ee00f3ba65e8ba)) +* **core:** Use project variable in executions ([#20275](https://github.com/n8n-io/n8n/issues/20275)) ([ca69904](https://github.com/n8n-io/n8n/commit/ca69904ad4a4c20f6370f1ebed2c27947a14d6f3)) +* Define node's waiting message in the node's description ([#20416](https://github.com/n8n-io/n8n/issues/20416)) ([d03a6c0](https://github.com/n8n-io/n8n/commit/d03a6c08e1ad3c5c5a5228b6e1d0483e1216c49c)) +* **editor:** Allow expressions to autocomplete project variables ([#20269](https://github.com/n8n-io/n8n/issues/20269)) ([2a7b341](https://github.com/n8n-io/n8n/commit/2a7b34197a63133cdeef8a9d6794b1e11bc08bc2)) +* **editor:** Create new variable page inside overview project page ([#20332](https://github.com/n8n-io/n8n/issues/20332)) ([cd0bbe2](https://github.com/n8n-io/n8n/commit/cd0bbe2d967fb33f212ccc71b933792e001d9a31)) +* **editor:** Improve community node tracking ([#20479](https://github.com/n8n-io/n8n/issues/20479)) ([07c60b2](https://github.com/n8n-io/n8n/commit/07c60b23ce5cf2021337d697920577f6ab1948b3)) +* **editor:** Introduce `Replace Node` context menu option ([#20287](https://github.com/n8n-io/n8n/issues/20287)) ([273840c](https://github.com/n8n-io/n8n/commit/273840c04216778165ca24dfaaa4176495866578)) +* **Ollama Node:** Add Ollama vendor with tool support and image analysis ([#19371](https://github.com/n8n-io/n8n/issues/19371)) ([c257a8f](https://github.com/n8n-io/n8n/commit/c257a8f92233d534f8ec636ea8e017bc966a5804)) +* **Redis Vector Store Node:** Redis vector store node implementation ([#19428](https://github.com/n8n-io/n8n/issues/19428)) ([f178a59](https://github.com/n8n-io/n8n/commit/f178a59702ac8803cbf01ac4cf6e0e1500b67f91)) +* Roll out Lucide icons to Nodes, remove FontAwesome icons ([#20477](https://github.com/n8n-io/n8n/issues/20477)) ([596cdfe](https://github.com/n8n-io/n8n/commit/596cdfec911054d699ff4343ea99e5d373597f35)) +* **Split Out Node:** Incorrect warning fix ([#20468](https://github.com/n8n-io/n8n/issues/20468)) ([fb501d6](https://github.com/n8n-io/n8n/commit/fb501d6ded58b605e26c74499866664fdaba85b2)) + + + # [1.115.0](https://github.com/n8n-io/n8n/compare/n8n@1.114.0...n8n@1.115.0) (2025-10-06) diff --git a/cypress/e2e/group1/19-execution.cy.ts b/cypress/e2e/group1/19-execution.cy.ts deleted file mode 100644 index c1887f375a7..00000000000 --- a/cypress/e2e/group1/19-execution.cy.ts +++ /dev/null @@ -1,608 +0,0 @@ -import { clickGetBackToCanvas, getNdvContainer, getOutputTableRow } from '../../composables/ndv'; -import { - clickExecuteWorkflowButton, - getExecuteWorkflowButton, - getNodeByName, - getZoomToFitButton, - openNode, -} from '../../composables/workflow'; -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'; - -const workflowPage = new WorkflowPageClass(); -const executionsTab = new WorkflowExecutionsTab(); -const ndv = new NDV(); - -describe('Execution', () => { - beforeEach(() => { - workflowPage.actions.visit(); - }); - - it('should test manual workflow', () => { - cy.createFixtureWorkflow('Manual_wait_set.json'); - - // Check workflow buttons - workflowPage.getters.executeWorkflowButton().should('be.visible'); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - workflowPage.getters.stopExecutionButton().should('not.exist'); - workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist'); - - // Execute the workflow - workflowPage.getters.zoomToFitButton().click(); - workflowPage.getters.executeWorkflowButton().click(); - - // Check workflow buttons - workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible'); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - workflowPage.getters.stopExecutionButton().should('be.visible'); - workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist'); - - // Check canvas nodes after 1st step (workflow passed the manual trigger node - workflowPage.getters - .canvasNodeByName('Manual') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=node-success]').should('not.exist')); - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=refresh-cw]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Set') - .within(() => cy.get('svg[data-icon=node-success]').should('not.exist')); - - cy.wait(2000); - - // Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start) - workflowPage.getters - .canvasNodeByName('Manual') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Set') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - - successToast().should('be.visible'); - clearNotifications(); - - // Clear execution data - workflowPage.getters.clearExecutionDataButton().should('be.visible'); - workflowPage.getters.clearExecutionDataButton().click(); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - }); - - it('should test manual workflow stop', () => { - cy.createFixtureWorkflow('Manual_wait_set.json'); - - // Check workflow buttons - workflowPage.getters.executeWorkflowButton().should('be.visible'); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - workflowPage.getters.stopExecutionButton().should('not.exist'); - workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist'); - - // Execute the workflow - workflowPage.getters.zoomToFitButton().click(); - workflowPage.getters.executeWorkflowButton().click(); - - // Check workflow buttons - workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible'); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - workflowPage.getters.stopExecutionButton().should('be.visible'); - workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist'); - - // Check canvas nodes after 1st step (workflow passed the manual trigger node - workflowPage.getters - .canvasNodeByName('Manual') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=node-success]').should('not.exist')); - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=refresh-cw]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Set') - .within(() => cy.get('svg[data-icon=node-success]').should('not.exist')); - - successToast().should('be.visible'); - clearNotifications(); - - workflowPage.getters.stopExecutionButton().should('exist'); - workflowPage.getters.stopExecutionButton().click(); - - // Check canvas nodes after workflow stopped - workflowPage.getters - .canvasNodeByName('Manual') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=refresh-cw]').should('not.exist')); - - workflowPage.getters - .canvasNodeByName('Set') - .within(() => cy.get('svg[data-icon=node-success]').should('not.exist')); - - successToast().should('be.visible'); - - // Clear execution data - workflowPage.getters.clearExecutionDataButton().should('be.visible'); - workflowPage.getters.clearExecutionDataButton().click(); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - }); - - it('should test webhook workflow', () => { - cy.createFixtureWorkflow('Webhook_wait_set.json'); - - // Check workflow buttons - workflowPage.getters.executeWorkflowButton().should('be.visible'); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - workflowPage.getters.stopExecutionButton().should('not.exist'); - workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist'); - - // Execute the workflow - workflowPage.getters.zoomToFitButton().click(); - workflowPage.getters.executeWorkflowButton().click(); - - // Check workflow buttons - workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible'); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - workflowPage.getters.stopExecutionButton().should('not.exist'); - workflowPage.getters.stopExecutionWaitingForWebhookButton().should('be.visible'); - - workflowPage.getters.canvasNodes().first().dblclick(); - - ndv.getters.copyInput().click(); - - cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite'); - - ndv.getters.backToCanvas().click(); - - cy.readClipboard().then((url) => { - cy.request({ - method: 'GET', - url, - }).then((resp) => { - expect(resp.status).to.eq(200); - }); - }); - - // Check canvas nodes after 1st step (workflow passed the manual trigger node - workflowPage.getters - .canvasNodeByName('Webhook') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=node-success]').should('not.exist')); - workflowPage.getters - .canvasNodeByName('Wait') - .within(() => cy.get('svg[data-icon=refresh-cw]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Set') - .within(() => cy.get('svg[data-icon=node-success]').should('not.exist')); - - cy.wait(2000); - - // Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start) - workflowPage.getters - .canvasNodeByName('Webhook') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Set') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - - successToast().should('be.visible'); - clearNotifications(); - - // Clear execution data - workflowPage.getters.clearExecutionDataButton().should('be.visible'); - workflowPage.getters.clearExecutionDataButton().click(); - workflowPage.getters.clearExecutionDataButton().should('not.exist'); - }); - - it('should test workflow with specific trigger node', () => { - cy.createFixtureWorkflow('Two_schedule_triggers.json'); - - getZoomToFitButton().click(); - getExecuteWorkflowButton('Trigger A').should('not.be.visible'); - getExecuteWorkflowButton('Trigger B').should('not.be.visible'); - - // Execute the workflow from trigger A - getNodeByName('Trigger A').realHover(); - getExecuteWorkflowButton('Trigger A').should('be.visible'); - getExecuteWorkflowButton('Trigger B').should('not.be.visible'); - clickExecuteWorkflowButton('Trigger A'); - - // Check the output - successToast().contains('Workflow executed successfully'); - openNode('Edit Fields'); - getOutputTableRow(1).should('include.text', 'Trigger A'); - - clickGetBackToCanvas(); - getNdvContainer().should('not.be.visible'); - - // Execute the workflow from trigger B - getNodeByName('Trigger B').realHover(); - getExecuteWorkflowButton('Trigger A').should('not.be.visible'); - getExecuteWorkflowButton('Trigger B').should('be.visible'); - clickExecuteWorkflowButton('Trigger B'); - - // Check the output - successToast().contains('Workflow executed successfully'); - openNode('Edit Fields'); - getOutputTableRow(1).should('include.text', 'Trigger B'); - }); - - describe('execution preview', () => { - it('when deleting the last execution, it should show empty state', () => { - workflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); - workflowPage.actions.executeWorkflow(); - executionsTab.actions.switchToExecutionsTab(); - - executionsTab.actions.deleteExecutionInPreview(); - - executionsTab.getters.successfulExecutionListItems().should('have.length', 0); - successToast().contains('Execution deleted'); - }); - }); - - /** - * @TODO New Canvas: Different classes for pinned states on edges and nodes - */ - // eslint-disable-next-line n8n-local-rules/no-skipped-tests - describe.skip('connections should be colored differently for pinned data', () => { - beforeEach(() => { - cy.createFixtureWorkflow('Schedule_pinned.json'); - workflowPage.actions.deselectAll(); - workflowPage.getters.zoomToFitButton().click(); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields1') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields5', 'Edit Fields6') - .should('not.have.class', 'success') - .should('not.have.class', 'pinned'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields7', 'Edit Fields9') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields1', 'Edit Fields2') - .should('not.have.class', 'success') - .should('not.have.class', 'pinned'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields2', 'Edit Fields3') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('not.have.class', 'has-run'); - }); - - it('when executing the workflow', () => { - workflowPage.actions.executeWorkflow(); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields1') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields5', 'Edit Fields6') - .should('have.class', 'success') - .should('not.have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields7', 'Edit Fields9') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields1', 'Edit Fields2') - .should('have.class', 'success') - .should('not.have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields2', 'Edit Fields3') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - }); - - it('when executing a node', () => { - workflowPage.actions.executeNode('Edit Fields3'); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields1') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields5', 'Edit Fields6') - .should('not.have.class', 'success') - .should('not.have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields7', 'Edit Fields9') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields1', 'Edit Fields2') - .should('have.class', 'success') - .should('not.have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields2', 'Edit Fields3') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - }); - - it('when connecting pinned node by output drag and drop', () => { - cy.drag( - workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME), - [-200, -300], - ); - workflowPage.getters.nodeCreatorSearchBar().should('be.visible'); - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); - cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], { - clickToFinish: true, - }); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.actions.executeWorkflow(); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - - cy.drag(workflowPage.getters.getEndpointSelector('output', 'Edit Fields2'), [-200, -300]); - workflowPage.getters.nodeCreatorSearchBar().should('be.visible'); - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); - cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], { - clickToFinish: true, - }); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields2', 'Edit Fields11') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - }); - - it('when connecting pinned node after adding an unconnected node', () => { - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); - - cy.draganddrop( - workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME), - workflowPage.getters.getEndpointSelector('input', 'Edit Fields8'), - ); - workflowPage.getters.zoomToFitButton().click(); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('not.have.class', 'has-run'); - - workflowPage.actions.executeWorkflow(); - - workflowPage.getters - .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - - workflowPage.actions.deselectAll(); - workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); - workflowPage.getters.zoomToFitButton().click(); - - cy.draganddrop( - workflowPage.getters.getEndpointSelector('output', 'Edit Fields7'), - workflowPage.getters.getEndpointSelector('input', 'Edit Fields11'), - ); - - workflowPage.getters - .getConnectionBetweenNodes('Edit Fields7', 'Edit Fields11') - .should('have.class', 'success') - .should('have.class', 'pinned') - .should('have.class', 'has-run'); - }); - }); - - it('should send proper payload for node rerun', () => { - cy.createFixtureWorkflow('Multiple_trigger_node_rerun.json'); - - workflowPage.getters.zoomToFitButton().click(); - workflowPage.getters.executeWorkflowButton().click(); - - workflowPage.getters.clearExecutionDataButton().should('be.visible'); - - cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); - - workflowPage.getters - .canvasNodeByName('Process The Data') - .findChildByTestId('execute-node-button') - .click({ force: true }); - - cy.wait('@workflowRun').then((interception) => { - expect(interception.request.body).to.have.property('runData').that.is.an('object'); - - const expectedKeys = ['Start on Schedule', 'Edit Fields', 'Process The Data']; - - const { runData } = interception.request.body; - expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length); - expect(runData).to.include.all.keys(expectedKeys); - }); - }); - - it('should send proper payload for manual node run', () => { - cy.createFixtureWorkflow('Check_manual_node_run_for_pinned_and_rundata.json'); - - workflowPage.getters.zoomToFitButton().click(); - - cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); - - workflowPage.getters - .canvasNodeByName('If') - .findChildByTestId('execute-node-button') - .click({ force: true }); - - cy.wait('@workflowRun').then((interception) => { - expect(interception.request.body).not.to.have.property('runData').that.is.an('object'); - expect(interception.request.body).to.have.property('workflowData').that.is.an('object'); - expect(interception.request.body.workflowData) - .to.have.property('pinData') - .that.is.an('object'); - const expectedPinnedDataKeys = ['Webhook']; - - const { pinData } = interception.request.body.workflowData as Record; - expect(Object.keys(pinData)).to.have.lengthOf(expectedPinnedDataKeys.length); - expect(pinData).to.include.all.keys(expectedPinnedDataKeys); - }); - - workflowPage.getters.clearExecutionDataButton().should('be.visible'); - - cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); - - workflowPage.getters - .canvasNodeByName('NoOp2') - .findChildByTestId('execute-node-button') - .click({ force: true }); - - cy.wait('@workflowRun').then((interception) => { - expect(interception.request.body).to.have.property('runData').that.is.an('object'); - expect(interception.request.body).to.have.property('workflowData').that.is.an('object'); - expect(interception.request.body.workflowData) - .to.have.property('pinData') - .that.is.an('object'); - const expectedPinnedDataKeys = ['Webhook']; - const expectedRunDataKeys = ['If', 'Webhook']; - - const { pinData } = interception.request.body.workflowData as Record; - expect(Object.keys(pinData)).to.have.lengthOf(expectedPinnedDataKeys.length); - expect(pinData).to.include.all.keys(expectedPinnedDataKeys); - - const { runData } = interception.request.body as Record; - expect(Object.keys(runData)).to.have.lengthOf(expectedRunDataKeys.length); - expect(runData).to.include.all.keys(expectedRunDataKeys); - }); - }); - - it('should successfully execute partial executions with nodes attached to the second output', () => { - cy.createFixtureWorkflow('Test_Workflow_pairedItem_incomplete_manual_bug.json'); - - cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); - - workflowPage.getters.zoomToFitButton().click(); - workflowPage.getters.executeWorkflowButton().click(); - workflowPage.getters - .canvasNodeByName('Test Expression') - .findChildByTestId('execute-node-button') - .click({ force: true }); - - // Check toast (works because Cypress waits enough for the element to show after the http request node has finished) - // Wait for the execution to return. - cy.wait('@workflowRun'); - // Wait again for the websocket message to arrive and the UI to update. - cy.wait(100); - errorToast({ timeout: 1 }).should('not.exist'); - }); - - it('should execute workflow partially up to the node that has issues', () => { - cy.createFixtureWorkflow('Test_workflow_partial_execution_with_missing_credentials.json'); - - cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); - - workflowPage.getters.zoomToFitButton().click(); - workflowPage.getters.executeWorkflowButton().click(); - - // Wait for the execution to return. - cy.wait('@workflowRun'); - - // Check that the previous nodes executed successfully - workflowPage.getters - .canvasNodeByName('DebugHelper') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - workflowPage.getters - .canvasNodeByName('Filter') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - - errorToast().should('contain', 'Problem in node ‘Telegram‘'); - }); - - it('Paired items should be correctly mapped after passed through the merge node with more than two inputs', () => { - cy.createFixtureWorkflow('merge_node_inputs_paired_items.json'); - - workflowPage.getters.zoomToFitButton().click(); - workflowPage.getters.executeWorkflowButton().click(); - - workflowPage.getters - .canvasNodeByName('Edit Fields') - .within(() => cy.get('svg[data-icon=node-success]')) - .should('exist'); - - workflowPage.getters.canvasNodeByName('Edit Fields').dblclick(); - ndv.actions.switchOutputMode('JSON'); - ndv.getters.outputPanel().contains('Branch 1 Value').should('be.visible'); - ndv.getters.outputPanel().contains('Branch 2 Value').should('be.visible'); - ndv.getters.outputPanel().contains('Branch 3 Value').should('be.visible'); - }); -}); diff --git a/cypress/e2e/group4/41-editors.cy.ts b/cypress/e2e/group4/41-editors.cy.ts deleted file mode 100644 index cd85465dc1b..00000000000 --- a/cypress/e2e/group4/41-editors.cy.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { WorkflowPage, NDV } from '../../pages'; - -const workflowPage = new WorkflowPage(); -const ndv = new NDV(); - -// Update is debounced in editors, so adding typing delay to catch up -const TYPING_DELAY = 100; - -describe('Editors', () => { - beforeEach(() => { - workflowPage.actions.visit(); - }); - - describe('SQL Editor', () => { - it('should preserve changes when opening-closing Postgres node', () => { - workflowPage.actions.addInitialNodeToCanvas('Postgres', { - action: 'Execute a SQL query', - keepNdvOpen: true, - }); - ndv.getters - .sqlEditorContainer() - .click() - .find('.cm-content') - .type('SELECT * FROM `testTable`', { delay: TYPING_DELAY }) - .type('{esc}'); - ndv.actions.close(); - workflowPage.actions.openNode('Execute a SQL query'); - ndv.getters - .sqlEditorContainer() - .find('.cm-content') - .type('{end} LIMIT 10', { delay: TYPING_DELAY }) - .type('{esc}'); - ndv.actions.close(); - workflowPage.actions.openNode('Execute a SQL query'); - ndv.getters.sqlEditorContainer().should('contain', 'SELECT * FROM `testTable` LIMIT 10'); - }); - - it('should update expression output dropdown as the query is edited', () => { - workflowPage.actions.addInitialNodeToCanvas('MySQL', { - action: 'Execute a SQL query', - }); - ndv.actions.close(); - - workflowPage.actions.openNode('When clicking ‘Execute workflow’'); - ndv.actions.setPinnedData([{ table: 'test_table' }]); - ndv.actions.close(); - - workflowPage.actions.openNode('Execute a SQL query'); - ndv.getters - .sqlEditorContainer() - .find('.cm-content') - // }} is inserted automatically by bracket matching - .type('SELECT * FROM {{ $json.table', { parseSpecialCharSequences: false }); - workflowPage.getters - .inlineExpressionEditorOutput() - .should('have.text', 'SELECT * FROM test_table'); - }); - - it('should not push NDV header out with a lot of code in Postgres editor', () => { - workflowPage.actions.addInitialNodeToCanvas('Postgres', { - action: 'Execute a SQL query', - keepNdvOpen: true, - }); - cy.fixture('Dummy_javascript.txt').then((code) => { - ndv.getters.sqlEditorContainer().find('.cm-content').paste(code); - }); - ndv.getters.nodeExecuteButton().should('be.visible'); - }); - - it('should not push NDV header out with a lot of code in MySQL editor', () => { - workflowPage.actions.addInitialNodeToCanvas('MySQL', { - action: 'Execute a SQL query', - keepNdvOpen: true, - }); - cy.fixture('Dummy_javascript.txt').then((code) => { - ndv.getters.sqlEditorContainer().find('.cm-content').paste(code); - }); - ndv.getters.nodeExecuteButton().should('be.visible'); - }); - - it('should not trigger dirty flag if nothing is changed', () => { - workflowPage.actions.addInitialNodeToCanvas('Postgres', { - action: 'Execute a SQL query', - keepNdvOpen: true, - }); - ndv.actions.close(); - workflowPage.actions.saveWorkflowOnButtonClick(); - workflowPage.getters.isWorkflowSaved(); - workflowPage.actions.openNode('Execute a SQL query'); - ndv.actions.close(); - // Workflow should still be saved - workflowPage.getters.isWorkflowSaved(); - }); - - it('should trigger dirty flag if query is updated', () => { - workflowPage.actions.addInitialNodeToCanvas('Postgres', { - action: 'Execute a SQL query', - keepNdvOpen: true, - }); - ndv.actions.close(); - workflowPage.actions.saveWorkflowOnButtonClick(); - workflowPage.getters.isWorkflowSaved(); - workflowPage.actions.openNode('Execute a SQL query'); - ndv.getters - .sqlEditorContainer() - .click() - .find('.cm-content') - .type('SELECT * FROM `testTable`', { delay: TYPING_DELAY }) - .type('{esc}'); - ndv.actions.close(); - workflowPage.getters.isWorkflowSaved().should('not.be.true'); - }); - - it('should allow switching between SQL editors in connected nodes', () => { - workflowPage.actions.addInitialNodeToCanvas('Postgres', { - action: 'Execute a SQL query', - keepNdvOpen: true, - }); - ndv.getters - .sqlEditorContainer() - .click() - .find('.cm-content') - .paste('SELECT * FROM `firstTable`'); - ndv.actions.close(); - - workflowPage.actions.addNodeToCanvas('Postgres', true, true, 'Execute a SQL query'); - ndv.getters - .sqlEditorContainer() - .click() - .find('.cm-content') - .paste('SELECT * FROM `secondTable`'); - ndv.actions.close(); - - workflowPage.actions.openNode('Execute a SQL query'); - ndv.actions.clickFloatingNode('Execute a SQL query1'); - ndv.getters - .sqlEditorContainer() - .find('.cm-content') - .should('have.text', 'SELECT * FROM `secondTable`'); - - ndv.actions.clickFloatingNode('Execute a SQL query'); - ndv.getters - .sqlEditorContainer() - .find('.cm-content') - .should('have.text', 'SELECT * FROM `firstTable`'); - }); - }); - - describe('HTML Editor', () => { - // Closing tags will be added by the editor - const TEST_ELEMENT_H1 = '

Test'; - const TEST_ELEMENT_P = '

Test'; - - it('should preserve changes when opening-closing HTML node', () => { - workflowPage.actions.addInitialNodeToCanvas('HTML', { - action: 'Generate HTML template', - keepNdvOpen: true, - }); - ndv.getters - .htmlEditorContainer() - .click() - .find('.cm-content') - .type(`{selectall}${TEST_ELEMENT_H1}`, { delay: TYPING_DELAY, force: true }) - .type('{esc}'); - ndv.actions.close(); - workflowPage.actions.openNode('HTML'); - ndv.getters - .htmlEditorContainer() - .find('.cm-content') - .type(`{end}${TEST_ELEMENT_P}`, { delay: TYPING_DELAY, force: true }) - .type('{esc}'); - ndv.actions.close(); - workflowPage.actions.openNode('HTML'); - ndv.getters.htmlEditorContainer().should('contain', TEST_ELEMENT_H1); - ndv.getters.htmlEditorContainer().should('contain', TEST_ELEMENT_P); - }); - - it('should not trigger dirty flag if nothing is changed', () => { - workflowPage.actions.addInitialNodeToCanvas('HTML', { - action: 'Generate HTML template', - keepNdvOpen: true, - }); - ndv.actions.close(); - workflowPage.actions.saveWorkflowOnButtonClick(); - workflowPage.getters.isWorkflowSaved(); - workflowPage.actions.openNode('HTML'); - ndv.actions.close(); - // Workflow should still be saved - workflowPage.getters.isWorkflowSaved(); - }); - - it('should trigger dirty flag if query is updated', () => { - workflowPage.actions.addInitialNodeToCanvas('HTML', { - action: 'Generate HTML template', - keepNdvOpen: true, - }); - ndv.actions.close(); - workflowPage.actions.saveWorkflowOnButtonClick(); - workflowPage.getters.isWorkflowSaved(); - workflowPage.actions.openNode('HTML'); - ndv.getters - .htmlEditorContainer() - .click() - .find('.cm-content') - .type(`{selectall}${TEST_ELEMENT_H1}`, { delay: TYPING_DELAY, force: true }) - .type('{esc}'); - ndv.actions.close(); - workflowPage.getters.isWorkflowSaved().should('not.be.true'); - }); - - it('should allow switching between HTML editors in connected nodes', () => { - workflowPage.actions.addInitialNodeToCanvas('HTML', { - action: 'Generate HTML template', - keepNdvOpen: true, - }); - ndv.getters - .htmlEditorContainer() - .click() - .find('.cm-content') - .type('{selectall}') - .paste('

First
'); - ndv.actions.close(); - - workflowPage.actions.addNodeToCanvas('HTML', true, true, 'Generate HTML template'); - ndv.getters - .htmlEditorContainer() - .click() - .find('.cm-content') - .type('{selectall}') - .paste('
Second
'); - ndv.actions.close(); - - workflowPage.actions.openNode('HTML'); - ndv.actions.clickFloatingNode('HTML1'); - ndv.getters - .htmlEditorContainer() - .find('.cm-content') - .should('have.text', '
Second
'); - - ndv.actions.clickFloatingNode('HTML'); - ndv.getters.htmlEditorContainer().find('.cm-content').should('have.text', '
First
'); - }); - }); -}); diff --git a/cypress/e2e/group5/17-workflow-tags.cy.ts b/cypress/e2e/group5/17-workflow-tags.cy.ts deleted file mode 100644 index 4be38019314..00000000000 --- a/cypress/e2e/group5/17-workflow-tags.cy.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { WorkflowPage } from '../../pages'; -import { getVisibleSelect } from '../../utils'; - -const wf = new WorkflowPage(); - -const TEST_TAGS = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4', 'Tag 5']; - -describe('Workflow tags', () => { - beforeEach(() => { - wf.actions.visit(); - }); - - it('should create and attach tags inline', () => { - wf.getters.createTagButton().click(); - wf.actions.addTags(TEST_TAGS.slice(0, 2)); - wf.getters.tagPills().should('have.length', 2); - wf.getters.nthTagPill(1).click(); - wf.actions.addTags(TEST_TAGS[2]); - wf.getters.tagPills().should('have.length', 3); - wf.getters.isWorkflowSaved(); - }); - - it('should create tags via modal', () => { - wf.actions.openTagManagerModal(); - - const tags = TEST_TAGS.slice(3); - for (const tag of tags) { - cy.contains('Add new').click(); - cy.getByTestId('tags-table').find('input').type(tag).type('{enter}'); - cy.wait(300); - } - cy.contains('Done').click(); - - wf.getters.createTagButton().click(); - wf.getters.tagsDropdown().click(); - wf.getters.tagsInDropdown().should('have.length', 5); - wf.getters.tagPills().should('have.length', 0); // none attached - }); - - it('should delete all tags via modal', () => { - wf.actions.openTagManagerModal(); - - TEST_TAGS.forEach(() => { - cy.getByTestId('delete-tag-button').first().click({ force: true }); - cy.contains('Delete tag').click(); - cy.wait(300); - }); - - cy.contains('Done').click(); - wf.getters.createTagButton().click(); - wf.getters.tagsInDropdown().should('have.length', 0); // none stored - wf.getters.tagPills().should('have.length', 0); // none attached - }); - - it('should detach a tag inline by clicking on X on tag pill', () => { - wf.getters.createTagButton().click(); - wf.actions.addTags(TEST_TAGS); - wf.getters.nthTagPill(1).click(); - wf.getters.tagsDropdown().find('.el-tag__close').first().click(); - cy.get('body').click(0, 0); - wf.getters.workflowTags().click(); - wf.getters.tagPills().should('have.length', TEST_TAGS.length - 1); - }); - - it('should detach a tag inline by clicking on dropdown list item', () => { - wf.getters.createTagButton().click(); - wf.actions.addTags(TEST_TAGS); - wf.getters.workflowTagsContainer().click(); - wf.getters.tagsInDropdown().filter('.selected').first().click(); - cy.get('body').click(0, 0); - wf.getters.workflowTags().click(); - wf.getters.tagPills().should('have.length', TEST_TAGS.length - 1); - }); - - it('should not show non existing tag as a selectable option', () => { - const NON_EXISTING_TAG = 'My Test Tag'; - - wf.getters.createTagButton().click(); - wf.actions.addTags(TEST_TAGS); - cy.get('body').click(0, 0); - wf.getters.workflowTags().click(); - wf.getters.workflowTagsInput().type(NON_EXISTING_TAG); - - getVisibleSelect() - .find('li') - .should('have.length', 2) - .filter(`:contains("${NON_EXISTING_TAG}")`) - .should('not.have.length'); - }); -}); diff --git a/docker/images/n8n-base/Dockerfile b/docker/images/n8n-base/Dockerfile index 77fe0b798e8..bd564a3559d 100644 --- a/docker/images/n8n-base/Dockerfile +++ b/docker/images/n8n-base/Dockerfile @@ -1,4 +1,4 @@ -ARG NODE_VERSION=22 +ARG NODE_VERSION=22.18.0 # ============================================================================== # STAGE 1: Builder for Base Dependencies @@ -16,6 +16,7 @@ RUN \ # Install essential OS dependencies RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.22/main" >> /etc/apk/repositories && echo "https://dl-cdn.alpinelinux.org/alpine/v3.22/community" >> /etc/apk/repositories && \ apk update && \ + apk add --no-cache libxml2 && \ apk add --no-cache \ git \ openssh \ diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 44c0af0ca71..4f20e3b7c3c 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -1,4 +1,4 @@ -ARG NODE_VERSION=22 +ARG NODE_VERSION=22.18.0 ARG N8N_VERSION=snapshot ARG LAUNCHER_VERSION=1.4.0 ARG TARGETPLATFORM diff --git a/docker/images/runners/Dockerfile b/docker/images/runners/Dockerfile index 87fcca76e0c..fdb8c45cb14 100644 --- a/docker/images/runners/Dockerfile +++ b/docker/images/runners/Dockerfile @@ -1,5 +1,5 @@ -ARG NODE_VERSION=22.19 -ARG PYTHON_VERSION=3.13 +ARG NODE_VERSION=22.18.0 +ARG PYTHON_VERSION=3.14 # ============================================================================== # STAGE 1: JavaScript runner (@n8n/task-runner) artifact from CI @@ -118,13 +118,12 @@ COPY --from=node-alpine /usr/local/bin/node /usr/local/bin/node RUN apk add --no-cache ca-certificates tini libstdc++ libc6-compat RUN addgroup -g 1000 -S runner \ - && adduser -u 1000 -S -G runner -h /home/runner -D runner \ - && install -d -o runner -g runner /opt/runners + && adduser -u 1000 -S -G runner -h /home/runner -D runner WORKDIR /home/runner -COPY --from=javascript-runner-builder --chown=runner:runner /app/task-runner-javascript /opt/runners/task-runner-javascript -COPY --from=python-runner-builder --chown=runner:runner /app/task-runner-python /opt/runners/task-runner-python +COPY --from=javascript-runner-builder --chown=root:root /app/task-runner-javascript /opt/runners/task-runner-javascript +COPY --from=python-runner-builder --chown=root:root /app/task-runner-python /opt/runners/task-runner-python COPY --from=launcher-downloader /launcher-bin/* /usr/local/bin/ COPY --chown=root:root docker/images/runners/n8n-task-runners.json /etc/n8n-task-runners.json diff --git a/package.json b/package.json index fce1e77c568..0762cf0c776 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.115.0", + "version": "1.116.0", "private": true, "engines": { "node": ">=22.16", diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts index 4566f16ccca..19a3c52176f 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts @@ -5,8 +5,11 @@ import type { WorkflowBuilderAgent } from '../../src/workflow-builder-agent'; import { evaluateWorkflow } from '../chains/workflow-evaluator'; import { programmaticEvaluation } from '../programmatic/programmatic'; import type { EvaluationInput, TestCase } from '../types/evaluation'; -import { isWorkflowStateValues } from '../types/langsmith'; +import { isWorkflowStateValues, safeExtractUsage } from '../types/langsmith'; import type { TestResult } from '../types/test-result'; +import { calculateCacheStats } from '../utils/cache-analyzer'; +import type { CacheLogger } from '../utils/cache-logger'; +import { extractPerMessageCacheStats } from '../utils/cache-logger'; import { consumeGenerator, getChatPayload } from '../utils/evaluation-helpers'; /** @@ -67,6 +70,7 @@ export function createErrorResult(testCase: TestCase, error: unknown): TestResul * @param llm - Language model for evaluation * @param testCase - Test case to execute * @param userId - User ID for the session + * @param cacheLogger - Optional logger for detailed per-message cache statistics * @returns Test result with generated workflow and evaluation */ export async function runSingleTest( @@ -75,6 +79,7 @@ export async function runSingleTest( testCase: TestCase, nodeTypes: INodeTypeDescription[], userId: string = 'test-user', + cacheLogger?: CacheLogger, ): Promise { try { // Generate workflow @@ -92,6 +97,18 @@ export async function runSingleTest( const generatedWorkflow = state.values.workflowJSON; + // Extract cache statistics from messages + const usage = safeExtractUsage(state.values.messages); + const cacheStats = calculateCacheStats(usage); + + // Log per-message cache statistics if logger provided + if (cacheLogger && state.values.messages) { + const perMessageStats = extractPerMessageCacheStats(state.values.messages); + for (const msgStats of perMessageStats) { + cacheLogger.logMessage(msgStats); + } + } + // Evaluate const evaluationInput: EvaluationInput = { userPrompt: testCase.prompt, @@ -108,6 +125,7 @@ export async function runSingleTest( evaluationResult, programmaticEvaluationResult, generationTime, + cacheStats, }; } catch (error) { return createErrorResult(testCase, error); diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts index 1408eb23fe2..fc96c3b428c 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts @@ -20,7 +20,13 @@ async function main(): Promise { : undefined; if (useLangsmith) { - await runLangsmithEvaluation(); + // Parse command line arguments for a number of repetitions + const repetitionsArg = process.argv.includes('--repetitions') + ? parseInt(process.argv[process.argv.indexOf('--repetitions') + 1], 10) + : 1; + const repetitions = Number.isNaN(repetitionsArg) ? 1 : repetitionsArg; + + await runLangsmithEvaluation(repetitions); } else { await runCliEvaluation(testCaseId); } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts index ffbe34da82c..8ea144ab2b9 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts @@ -74,9 +74,13 @@ function createWorkflowGenerator( /** * Runs evaluation using Langsmith + * @param repetitions - Number of times to run each example (default: 1) */ -export async function runLangsmithEvaluation(): Promise { +export async function runLangsmithEvaluation(repetitions: number = 1): Promise { console.log(formatHeader('AI Workflow Builder Langsmith Evaluation', 70)); + if (repetitions > 1) { + console.log(pc.yellow(`➔ Each example will be run ${repetitions} times`)); + } console.log(); // Check for Langsmith API key @@ -130,6 +134,7 @@ export async function runLangsmithEvaluation(): Promise { evaluators: [evaluator], maxConcurrency: 7, experimentPrefix: 'workflow-builder-evaluation', + numRepetitions: repetitions, metadata: { evaluationType: 'llm-based', modelName: process.env.LLM_MODEL ?? 'default', diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/test-cache-quality.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/test-cache-quality.ts new file mode 100644 index 00000000000..656b6870c81 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/test-cache-quality.ts @@ -0,0 +1,381 @@ +#!/usr/bin/env node +/** + * Cache Quality Testing Script + * + * This script runs specialized tests to measure prompt caching effectiveness: + * 1. Sequential test runs to measure cache hit rates + * 2. Multi-turn conversations to test cache reuse + * 3. Cost analysis and cache effectiveness metrics + */ + +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { MemorySaver } from '@langchain/langgraph'; +import Table from 'cli-table3'; +import { promises as fs } from 'fs'; +import path from 'path'; +import pc from 'picocolors'; + +import { basicTestCases } from './chains/test-case-generator.js'; +import { setupTestEnvironment } from './core/environment.js'; +import { runSingleTest } from './core/test-runner.js'; +import type { TestCase } from './types/evaluation.js'; +import type { TestResult, CacheStatistics } from './types/test-result.js'; +import { + aggregateCacheStats, + formatCacheStats, + calculateCacheEffectiveness, +} from './utils/cache-analyzer.js'; +import { CacheLogger } from './utils/cache-logger.js'; +import { createAgent } from './utils/evaluation-helpers.js'; + +interface CacheTestConfig { + iterations: number; + testCases: TestCase[]; + outputDir: string; +} + +/** + * Run cache quality tests with multiple iterations + */ +async function runCacheQualityTest(config: CacheTestConfig): Promise { + console.log(pc.cyan('\n╔════════════════════════════════════════════════════════╗')); + console.log(pc.cyan('║ Prompt Caching Quality Test ║')); + console.log(pc.cyan('╚════════════════════════════════════════════════════════╝\n')); + + const { llm, parsedNodeTypes, tracer } = await setupTestEnvironment(); + const allResults: TestResult[][] = []; + const iterationStats: CacheStatistics[] = []; + console.log( + pc.dim( + `Running ${config.iterations} iterations with ${config.testCases.length} test cases...\n`, + ), + ); + + // Run multiple iterations + for (let iteration = 0; iteration < config.iterations; iteration++) { + console.log(pc.cyan(`\n═══ Iteration ${iteration + 1}/${config.iterations} ═══\n`)); + const checkpointer = new MemorySaver(); + const iterationResults: TestResult[] = []; + + for (const testCase of config.testCases) { + console.log(pc.dim(` Running: ${testCase.name}...`)); + + const cacheLogger = new CacheLogger(iteration + 1, testCase.id, config.outputDir); + cacheLogger.logTestHeader(testCase.name, iteration + 1); + + const agent = createAgent(parsedNodeTypes, llm, tracer, checkpointer); + + const result = await runSingleTest( + agent, + llm, + testCase, + parsedNodeTypes, + `test-user-iter-${iteration}`, + cacheLogger, + ); + + if (result.error) { + console.log(pc.red(` ✗ Failed: ${result.error}`)); + } else { + const score = (result.evaluationResult.overallScore * 100).toFixed(1); + const cacheInfo = result.cacheStats + ? ` [Cache: ${(result.cacheStats.cacheHitRate * 100).toFixed(1)}%]` + : ''; + console.log(pc.green(` ✓ Score: ${score}%${cacheInfo}`)); + + // Log test footer with summary + if (result.cacheStats) { + cacheLogger.logTestFooter( + 1, // We don't track exact message count here + result.cacheStats.cacheReadTokens, + result.cacheStats.cacheCreationTokens, + result.cacheStats.cacheHitRate, + ); + } + } + + // Write cache log to file + await cacheLogger.flush(); + + iterationResults.push(result); + } + + allResults.push(iterationResults); + + // Calculate stats for this iteration + const validResults = iterationResults.filter((r) => r.cacheStats); + if (validResults.length > 0) { + const stats = validResults.map((r) => r.cacheStats!); + const iterationAggregate = aggregateCacheStats(stats); + iterationStats.push(iterationAggregate); + + console.log( + pc.dim( + `\n Iteration Cache Hit Rate: ${pc.bold((iterationAggregate.cacheHitRate * 100).toFixed(2))}%`, + ), + ); + } + } + + // Analyze and display results + await analyzeCacheQuality(allResults, iterationStats, config, llm); +} + +/** + * Analyze cache quality across iterations + */ +async function analyzeCacheQuality( + allResults: TestResult[][], + iterationStats: CacheStatistics[], + config: CacheTestConfig, + llm: BaseChatModel, +): Promise { + console.log(pc.cyan('\n\n╔════════════════════════════════════════════════════════╗')); + console.log(pc.cyan('║ Cache Quality Analysis ║')); + console.log(pc.cyan('╚════════════════════════════════════════════════════════╝\n')); + + // Overall statistics + const overallStats = aggregateCacheStats(iterationStats); + const formatted = formatCacheStats(overallStats); + + const statsTable = new Table({ + head: ['Metric', 'Value'], + style: { head: ['cyan'] }, + }); + + const hitRateColor = + overallStats.cacheHitRate > 0.6 + ? pc.green + : overallStats.cacheHitRate > 0.3 + ? pc.yellow + : pc.red; + + statsTable.push( + ['Total Iterations', config.iterations.toString()], + ['Test Cases per Iteration', config.testCases.length.toString()], + [pc.dim('─'.repeat(25)), pc.dim('─'.repeat(25))], + ['Total Input Tokens', formatted.inputTokens], + ['Total Output Tokens', formatted.outputTokens], + ['Cache Creation Tokens', formatted.cacheCreationTokens], + ['Cache Read Tokens', formatted.cacheReadTokens], + [pc.dim('─'.repeat(25)), pc.dim('─'.repeat(25))], + ['Overall Cache Hit Rate', hitRateColor(formatted.cacheHitRate)], + ['Total Cost Savings', pc.green(formatted.costSavings)], + [ + 'Cache Effectiveness', + hitRateColor((calculateCacheEffectiveness(overallStats) * 100).toFixed(1) + '%'), + ], + ); + + console.log(statsTable.toString()); + + // Iteration-by-iteration analysis + if (iterationStats.length > 1) { + console.log(pc.cyan('\n\n═══ Cache Hit Rate Progression ═══\n')); + + const progressTable = new Table({ + head: ['Iteration', 'Hit Rate', 'Cache Reads', 'Cost Savings'], + style: { head: ['cyan'] }, + }); + + iterationStats.forEach((stats, idx) => { + const hitRate = (stats.cacheHitRate * 100).toFixed(2) + '%'; + const hitRateColored = + stats.cacheHitRate > 0.6 + ? pc.green(hitRate) + : stats.cacheHitRate > 0.3 + ? pc.yellow(hitRate) + : pc.red(hitRate); + + progressTable.push([ + (idx + 1).toString(), + hitRateColored, + stats.cacheReadTokens.toLocaleString(), + `$${stats.estimatedCostSavings.toFixed(4)}`, + ]); + }); + + console.log(progressTable.toString()); + + // Check if cache improves over iterations + const firstHitRate = iterationStats[0].cacheHitRate; + const lastHitRate = iterationStats[iterationStats.length - 1].cacheHitRate; + const improvement = lastHitRate - firstHitRate; + + if (improvement > 0.1) { + console.log( + pc.green( + `\n ✓ Cache hit rate improved by ${(improvement * 100).toFixed(1)}% from first to last iteration`, + ), + ); + } else if (improvement < -0.1) { + console.log( + pc.red( + `\n ✗ Cache hit rate decreased by ${Math.abs(improvement * 100).toFixed(1)}% - potential issue`, + ), + ); + } + } + + // Per-test-case analysis + console.log(pc.cyan('\n\n═══ Per Test Case Analysis ═══\n')); + + const testTable = new Table({ + head: ['Test Case', 'Avg Hit Rate', 'Avg Score', 'Total Savings'], + style: { head: ['cyan'] }, + }); + + for (const testCase of config.testCases) { + const testResults = allResults.flatMap((iter) => + iter.filter((r) => r.testCase.id === testCase.id && r.cacheStats), + ); + + if (testResults.length > 0) { + const avgHitRate = + testResults.reduce((sum, r) => sum + (r.cacheStats?.cacheHitRate ?? 0), 0) / + testResults.length; + const avgScore = + testResults.reduce((sum, r) => sum + r.evaluationResult.overallScore, 0) / + testResults.length; + const totalSavings = testResults.reduce( + (sum, r) => sum + (r.cacheStats?.estimatedCostSavings ?? 0), + 0, + ); + + const hitRateStr = (avgHitRate * 100).toFixed(1) + '%'; + const hitRateColored = + avgHitRate > 0.6 + ? pc.green(hitRateStr) + : avgHitRate > 0.3 + ? pc.yellow(hitRateStr) + : pc.red(hitRateStr); + + testTable.push([ + testCase.name, + hitRateColored, + (avgScore * 100).toFixed(1) + '%', + `$${totalSavings.toFixed(4)}`, + ]); + } + } + + console.log(testTable.toString()); + + // Recommendations + console.log(pc.cyan('\n\n═══ Recommendations ═══\n')); + + if (overallStats.cacheHitRate > 0.6) { + console.log( + pc.green(' ✓ Excellent caching performance! The current cache configuration is effective.'), + ); + } else if (overallStats.cacheHitRate > 0.3) { + console.log(pc.yellow(' ⚠ Moderate caching performance. Consider:')); + console.log(pc.yellow(' - Review cache_control markers in prompts')); + console.log(pc.yellow(' - Ensure static content is marked for caching')); + console.log(pc.yellow(' - Check that dynamic content is excluded from cache')); + } else { + console.log(pc.red(' ✗ Low caching performance. Action needed:')); + console.log(pc.red(' - Verify cache_control markers are set correctly')); + console.log(pc.red(' - Check that anthropic-beta header is present')); + console.log(pc.red(' - Review prompt structure for cacheable content')); + } + + // Save detailed report + await saveDetailedReport(allResults, iterationStats, config, overallStats, llm); +} + +/** + * Save detailed JSON report + */ +async function saveDetailedReport( + allResults: TestResult[][], + iterationStats: CacheStatistics[], + config: CacheTestConfig, + overallStats: CacheStatistics, + llm: BaseChatModel, +): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const reportPath = path.join( + config.outputDir, + `cache-quality-report-${timestamp.split('T')[0]}.json`, + ); + + const report = { + metadata: { + timestamp: new Date().toISOString(), + iterations: config.iterations, + testCases: config.testCases.length, + model: llm._llmType?.() ?? 'unknown', + }, + summary: { + overallCacheHitRate: overallStats.cacheHitRate, + totalCostSavings: overallStats.estimatedCostSavings, + cacheEffectiveness: calculateCacheEffectiveness(overallStats), + }, + iterationStats, + detailedResults: allResults, + }; + + await fs.mkdir(config.outputDir, { recursive: true }); + await fs.writeFile(reportPath, JSON.stringify(report, null, 2)); + + console.log(pc.dim(`\n\n Report saved to: ${reportPath}`)); +} + +/** + * Main execution + */ +async function main(): Promise { + // Choose test cases based on environment variable + const useBasicTestCases = process.env.CACHE_TEST_USE_BASIC_CASES === 'true'; + + const testCases: TestCase[] = useBasicTestCases + ? basicTestCases + : [ + { + id: 'simple-http-request', + name: 'Simple HTTP Request', + prompt: 'Create a workflow that fetches data from https://api.example.com/users', + }, + { + id: 'data-transformation', + name: 'Data Transformation', + prompt: + 'Create a workflow that fetches JSON data, filters items where status is "active", and transforms the results', + }, + { + id: 'conditional-workflow', + name: 'Conditional Workflow', + prompt: + 'Create a workflow with an IF node that checks if data exists, and sends different HTTP requests based on the condition', + }, + ]; + + const iterations = parseInt(process.env.CACHE_TEST_ITERATIONS ?? '3', 10); + const outputDir = path.join(process.cwd(), 'evaluations', 'results', 'cache-tests'); + + console.log( + pc.dim( + `\nUsing ${useBasicTestCases ? '10 comprehensive test cases' : '3 simple test cases'}\n`, + ), + ); + + const config: CacheTestConfig = { + iterations, + testCases, + outputDir, + }; + + await runCacheQualityTest(config); +} + +// Run if executed directly +const isMainModule = require.main === module || process.argv[1]?.includes('test-cache-quality'); +if (isMainModule) { + main().catch((error) => { + console.error(pc.red('\nCache quality test failed:'), error); + process.exit(1); + }); +} + +export { runCacheQualityTest, analyzeCacheQuality }; diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/types/test-result.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/types/test-result.ts index 468cb5e9b6a..c8cc3fb2ccc 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/types/test-result.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/types/test-result.ts @@ -15,6 +15,34 @@ export interface ProgrammaticEvaluationResult { fromAi: SingleEvaluatorResult; } +/** + * Cache statistics for prompt caching analysis + */ +export interface CacheStatistics { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cacheHitRate: number; + estimatedCostSavings: number; +} + +/** + * Cache statistics for a single message/API call + */ +export interface MessageCacheStats { + messageIndex: number; + timestamp: string; + messageType: 'user' | 'assistant' | 'tool_call' | 'tool_response'; + role?: string; + toolName?: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cacheHitRate: number; +} + /** * Result of running a single test case */ @@ -24,5 +52,6 @@ export interface TestResult { evaluationResult: EvaluationResult; programmaticEvaluationResult: ProgrammaticEvaluationResult; generationTime: number; + cacheStats?: CacheStatistics; error?: string; } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/__tests__/cache-analyzer.test.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/__tests__/cache-analyzer.test.ts new file mode 100644 index 00000000000..6e1b6eb5a0c --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/__tests__/cache-analyzer.test.ts @@ -0,0 +1,180 @@ +import type { UsageMetadata } from '../../types/langsmith'; +import type { CacheStatistics } from '../../types/test-result'; +import { + calculateCacheStats, + calculateCostSavings, + aggregateCacheStats, + formatCacheStats, + calculateCacheEffectiveness, +} from '../cache-analyzer'; + +describe('cache-analyzer', () => { + describe('calculateCacheStats', () => { + it('should calculate cache statistics correctly', () => { + const usage: Partial = { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 2000, + cache_read_input_tokens: 3000, + }; + + const stats = calculateCacheStats(usage); + + expect(stats.inputTokens).toBe(1000); + expect(stats.outputTokens).toBe(500); + expect(stats.cacheCreationTokens).toBe(2000); + expect(stats.cacheReadTokens).toBe(3000); + expect(stats.cacheHitRate).toBeCloseTo(0.5, 2); // 3000 / (1000 + 2000 + 3000) + }); + + it('should handle zero tokens', () => { + const usage: Partial = { + input_tokens: 0, + output_tokens: 0, + }; + + const stats = calculateCacheStats(usage); + + expect(stats.cacheHitRate).toBe(0); + expect(stats.estimatedCostSavings).toBe(0); + }); + + it('should handle undefined cache tokens', () => { + const usage: Partial = { + input_tokens: 1000, + output_tokens: 500, + }; + + const stats = calculateCacheStats(usage); + + expect(stats.cacheCreationTokens).toBe(0); + expect(stats.cacheReadTokens).toBe(0); + expect(stats.cacheHitRate).toBe(0); + }); + }); + + describe('calculateCostSavings', () => { + it('should calculate cost savings correctly', () => { + const usage: Partial = { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 2000, + cache_read_input_tokens: 3000, + }; + + const savings = calculateCostSavings(usage); + + // Without cache: ((1000 + 2000 + 3000) * $3.00 + 500 * $15.00) / 1M = $0.0255 + // With cache: (1000 * $3.00 + 2000 * $3.75 + 3000 * $0.30 + 500 * $15.00) / 1M = $0.0189 + // Savings: $0.0255 - $0.0189 = $0.0066 + expect(savings).toBeCloseTo(0.0066, 4); + }); + + it('should return zero savings when no cache is used', () => { + const usage: Partial = { + input_tokens: 1000, + output_tokens: 500, + }; + + const savings = calculateCostSavings(usage); + + expect(savings).toBe(0); + }); + }); + + describe('aggregateCacheStats', () => { + it('should aggregate multiple cache statistics', () => { + const stats: CacheStatistics[] = [ + { + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 2000, + cacheReadTokens: 3000, + cacheHitRate: 0.5, + estimatedCostSavings: 0.008, + }, + { + inputTokens: 1500, + outputTokens: 750, + cacheCreationTokens: 2500, + cacheReadTokens: 3500, + cacheHitRate: 0.6, + estimatedCostSavings: 0.012, + }, + ]; + + const aggregate = aggregateCacheStats(stats); + + expect(aggregate.inputTokens).toBe(2500); + expect(aggregate.outputTokens).toBe(1250); + expect(aggregate.cacheCreationTokens).toBe(4500); + expect(aggregate.cacheReadTokens).toBe(6500); + expect(aggregate.estimatedCostSavings).toBeCloseTo(0.02, 3); + // Cache hit rate recalculated: 6500 / (2500 + 4500 + 6500) + expect(aggregate.cacheHitRate).toBeCloseTo(0.4815, 3); + }); + + it('should handle empty array', () => { + const aggregate = aggregateCacheStats([]); + + expect(aggregate.inputTokens).toBe(0); + expect(aggregate.cacheHitRate).toBe(0); + }); + }); + + describe('formatCacheStats', () => { + it('should format statistics for display', () => { + const stats: CacheStatistics = { + inputTokens: 12345, + outputTokens: 6789, + cacheCreationTokens: 15000, + cacheReadTokens: 30000, + cacheHitRate: 0.6677, + estimatedCostSavings: 0.0523, + }; + + const formatted = formatCacheStats(stats); + + expect(formatted.inputTokens).toBe('12,345'); + expect(formatted.outputTokens).toBe('6,789'); + expect(formatted.cacheCreationTokens).toBe('15,000'); + expect(formatted.cacheReadTokens).toBe('30,000'); + expect(formatted.cacheHitRate).toBe('66.77%'); + expect(formatted.costSavings).toBe('$0.0523'); + }); + }); + + describe('calculateCacheEffectiveness', () => { + it('should calculate effectiveness based on hit rate and cost savings', () => { + const stats: CacheStatistics = { + inputTokens: 10000, + outputTokens: 5000, + cacheCreationTokens: 15000, + cacheReadTokens: 30000, + cacheHitRate: 0.6, + estimatedCostSavings: 0.05, + }; + + const effectiveness = calculateCacheEffectiveness(stats); + + // (0.6 * 0.6) + (min(0.05 / 0.1, 1) * 0.4) = 0.36 + 0.2 = 0.56 + expect(effectiveness).toBeCloseTo(0.56, 2); + }); + + it('should cap cost savings contribution at 1.0', () => { + const stats: CacheStatistics = { + inputTokens: 10000, + outputTokens: 5000, + cacheCreationTokens: 15000, + cacheReadTokens: 30000, + cacheHitRate: 0.8, + estimatedCostSavings: 1.0, // Very high savings + }; + + const effectiveness = calculateCacheEffectiveness(stats); + + // (0.8 * 0.6) + (1.0 * 0.4) = 0.48 + 0.4 = 0.88 + expect(effectiveness).toBeCloseTo(0.88, 2); + }); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/cache-analyzer.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/cache-analyzer.ts new file mode 100644 index 00000000000..bea5c784161 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/cache-analyzer.ts @@ -0,0 +1,138 @@ +import type { UsageMetadata } from '../types/langsmith.js'; +import type { CacheStatistics } from '../types/test-result.js'; + +/** + * Anthropic pricing (as of 2024) + * Source: https://www.anthropic.com/pricing + */ +const PRICING = { + // Claude Sonnet 4 pricing per million tokens + inputTokensPerMillion: 3.0, + outputTokensPerMillion: 15.0, + cacheWritePerMillion: 3.75, // 25% markup on input tokens + cacheReadPerMillion: 0.3, // 90% discount on input tokens +}; + +/** + * Calculate cache statistics from usage metadata + */ +export function calculateCacheStats(usage: Partial): CacheStatistics { + const inputTokens = usage.input_tokens ?? 0; + const outputTokens = usage.output_tokens ?? 0; + const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0; + const cacheReadTokens = usage.cache_read_input_tokens ?? 0; + + // Calculate cache hit rate + // Cache hit rate = cache read tokens / (cache read + non-cached input tokens) + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; + const cacheHitRate = totalInputTokens > 0 ? cacheReadTokens / totalInputTokens : 0; + + // Calculate cost savings + const estimatedCostSavings = calculateCostSavings(usage); + + return { + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + cacheHitRate, + estimatedCostSavings, + }; +} + +/** + * Calculate cost savings from using cache vs not using cache + * Returns the savings in dollars + */ +export function calculateCostSavings(usage: Partial): number { + const inputTokens = usage.input_tokens ?? 0; + const outputTokens = usage.output_tokens ?? 0; + const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0; + const cacheReadTokens = usage.cache_read_input_tokens ?? 0; + + // Cost with caching + const costWithCache = + (inputTokens / 1_000_000) * PRICING.inputTokensPerMillion + + (outputTokens / 1_000_000) * PRICING.outputTokensPerMillion + + (cacheCreationTokens / 1_000_000) * PRICING.cacheWritePerMillion + + (cacheReadTokens / 1_000_000) * PRICING.cacheReadPerMillion; + + // Cost without caching (all tokens would be regular input tokens) + const totalInputWithoutCache = inputTokens + cacheCreationTokens + cacheReadTokens; + const costWithoutCache = + (totalInputWithoutCache / 1_000_000) * PRICING.inputTokensPerMillion + + (outputTokens / 1_000_000) * PRICING.outputTokensPerMillion; + + return costWithoutCache - costWithCache; +} + +/** + * Calculate aggregate cache statistics from multiple test results + */ +export function aggregateCacheStats(stats: CacheStatistics[]): CacheStatistics { + if (stats.length === 0) { + return { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cacheHitRate: 0, + estimatedCostSavings: 0, + }; + } + + const totalInputTokens = stats.reduce((sum, s) => sum + s.inputTokens, 0); + const totalOutputTokens = stats.reduce((sum, s) => sum + s.outputTokens, 0); + const totalCacheCreation = stats.reduce((sum, s) => sum + s.cacheCreationTokens, 0); + const totalCacheRead = stats.reduce((sum, s) => sum + s.cacheReadTokens, 0); + const totalCostSavings = stats.reduce((sum, s) => sum + s.estimatedCostSavings, 0); + + // Recalculate aggregate cache hit rate + const totalTokens = totalInputTokens + totalCacheCreation + totalCacheRead; + const aggregateCacheHitRate = totalTokens > 0 ? totalCacheRead / totalTokens : 0; + + return { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cacheHitRate: aggregateCacheHitRate, + estimatedCostSavings: totalCostSavings, + }; +} + +/** + * Format cache statistics for display + */ +export function formatCacheStats(stats: CacheStatistics): { + inputTokens: string; + outputTokens: string; + cacheCreationTokens: string; + cacheReadTokens: string; + cacheHitRate: string; + costSavings: string; +} { + return { + inputTokens: stats.inputTokens.toLocaleString(), + outputTokens: stats.outputTokens.toLocaleString(), + cacheCreationTokens: stats.cacheCreationTokens.toLocaleString(), + cacheReadTokens: stats.cacheReadTokens.toLocaleString(), + cacheHitRate: `${(stats.cacheHitRate * 100).toFixed(2)}%`, + costSavings: `$${stats.estimatedCostSavings.toFixed(4)}`, + }; +} + +/** + * Calculate cache effectiveness score (0-1) + * Based on cache hit rate and cost savings + */ +export function calculateCacheEffectiveness(stats: CacheStatistics): number { + // Weight cache hit rate and cost savings + const hitRateWeight = 0.6; + const costSavingsWeight = 0.4; + + // Normalize cost savings (assume $0.10 savings is excellent) + const normalizedSavings = Math.min(stats.estimatedCostSavings / 0.1, 1); + + return stats.cacheHitRate * hitRateWeight + normalizedSavings * costSavingsWeight; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/cache-logger.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/cache-logger.ts new file mode 100644 index 00000000000..bb0d91989d1 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/cache-logger.ts @@ -0,0 +1,226 @@ +import type { BaseMessage } from '@langchain/core/messages'; +import { promises as fs } from 'fs'; +import path from 'path'; +import pc from 'picocolors'; + +import type { MessageCacheStats } from '../types/test-result'; + +interface UsageMetadata { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; +} + +interface MessageWithMetadata { + response_metadata?: { + usage?: UsageMetadata; + }; + constructor?: { + name?: string; + }; + _getType?: () => string; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ name: string }>; +} + +/** + * Logger for detailed per-message cache statistics + */ +export class CacheLogger { + private logPath: string; + private logEntries: string[] = []; + + constructor(iteration: number, testCaseId: string, baseDir: string = 'results/cache-logs') { + const iterationDir = path.join(baseDir, `iteration-${iteration}`); + this.logPath = path.join(iterationDir, `${testCaseId}.log`); + } + + /** + * Log a single message's cache statistics + */ + logMessage(stats: MessageCacheStats): void { + const lines: string[] = []; + + // Header + lines.push(''); + lines.push('━'.repeat(70)); + lines.push(`[${stats.timestamp}] Message #${stats.messageIndex} (${stats.messageType})`); + + // Message details + if (stats.role) { + lines.push(` Role: ${stats.role}`); + } + if (stats.toolName) { + lines.push(` Tool: ${stats.toolName}`); + } + + // Token usage + lines.push(''); + lines.push(' Token Usage:'); + if (stats.inputTokens > 0) { + lines.push(` Input Tokens: ${stats.inputTokens.toLocaleString()}`); + } + if (stats.outputTokens > 0) { + lines.push(` Output Tokens: ${stats.outputTokens.toLocaleString()}`); + } + if (stats.cacheCreationTokens > 0) { + lines.push(` Cache Creation Tokens: ${stats.cacheCreationTokens.toLocaleString()} ✍️`); + } + if (stats.cacheReadTokens > 0) { + lines.push(` Cache Read Tokens: ${stats.cacheReadTokens.toLocaleString()} ⚡`); + } + + // Cache performance + if (stats.cacheReadTokens > 0 || stats.cacheCreationTokens > 0) { + lines.push(''); + lines.push(' Cache Performance:'); + const hitRatePercent = (stats.cacheHitRate * 100).toFixed(2); + const hitRateEmoji = stats.cacheHitRate > 0.6 ? '🔥' : stats.cacheHitRate > 0.3 ? '⚡' : '❄️'; + lines.push(` Hit Rate: ${hitRatePercent}% ${hitRateEmoji}`); + + // Show cache warming indicator + if (stats.cacheReadTokens > 0) { + lines.push(' Status: Cache hit - tokens served from cache'); + } else if (stats.cacheCreationTokens > 0) { + lines.push(' Status: Cache miss - tokens written to cache'); + } + } + + this.logEntries.push(lines.join('\n')); + } + + /** + * Log a summary header for the test + */ + logTestHeader(testName: string, iteration: number): void { + const lines: string[] = []; + lines.push(''); + lines.push('═'.repeat(70)); + lines.push(` Test: ${testName}`); + lines.push(` Iteration: ${iteration}`); + lines.push(` Started: ${new Date().toISOString()}`); + lines.push('═'.repeat(70)); + lines.push(''); + + this.logEntries.push(lines.join('\n')); + } + + /** + * Log a summary footer with aggregate statistics + */ + logTestFooter( + totalMessages: number, + totalCacheReads: number, + totalCacheCreations: number, + overallHitRate: number, + ): void { + const lines: string[] = []; + lines.push(''); + lines.push('━'.repeat(70)); + lines.push(' Test Summary:'); + lines.push(` Total Messages: ${totalMessages}`); + lines.push(` Total Cache Reads: ${totalCacheReads.toLocaleString()}`); + lines.push(` Total Cache Creations: ${totalCacheCreations.toLocaleString()}`); + lines.push(` Overall Hit Rate: ${(overallHitRate * 100).toFixed(2)}%`); + lines.push('━'.repeat(70)); + lines.push(''); + + this.logEntries.push(lines.join('\n')); + } + + /** + * Write all log entries to the file + */ + async flush(): Promise { + const dir = path.dirname(this.logPath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(this.logPath, this.logEntries.join('\n')); + console.log(pc.dim(` Cache log saved: ${this.logPath}`)); + } + + /** + * Get the log file path + */ + getLogPath(): string { + return this.logPath; + } +} + +/** + * Determines message type and tool name from a message + */ +function determineMessageType(message: MessageWithMetadata): { + messageType: MessageCacheStats['messageType']; + toolName: string | undefined; +} { + let messageType: MessageCacheStats['messageType'] = 'user'; + let toolName: string | undefined; + + const constructorName = message.constructor?.name; + const messageTypeStr = message._getType?.(); + + // Check for tool calls first + if (message.tool_calls && message.tool_calls.length > 0) { + return { + messageType: 'tool_call', + toolName: message.tool_calls[0].name, + }; + } + + // Determine based on constructor or type + if (constructorName === 'AIMessage' || messageTypeStr === 'ai') { + messageType = 'assistant'; + } else if (constructorName === 'HumanMessage' || messageTypeStr === 'human') { + messageType = 'user'; + } else if (constructorName === 'ToolMessage' || messageTypeStr === 'tool') { + messageType = 'tool_response'; + toolName = message.name ?? message.tool_call_id; + } + + return { messageType, toolName }; +} + +/** + * Extract per-message cache statistics from message history + */ +export function extractPerMessageCacheStats( + messages: BaseMessage[] | MessageWithMetadata[], +): MessageCacheStats[] { + const stats: MessageCacheStats[] = []; + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] as MessageWithMetadata; + const usage = message.response_metadata?.usage; + + if (!usage) { + continue; + } + + const { messageType, toolName } = determineMessageType(message); + + const inputTokens = usage.input_tokens ?? 0; + const outputTokens = usage.output_tokens ?? 0; + const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0; + const cacheReadTokens = usage.cache_read_input_tokens ?? 0; + + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; + const cacheHitRate = totalInputTokens > 0 ? cacheReadTokens / totalInputTokens : 0; + + stats.push({ + messageIndex: i + 1, + timestamp: new Date().toISOString(), + messageType, + role: message._getType?.(), + toolName, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + cacheHitRate, + }); + } + + return stats; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-helpers.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-helpers.ts index bffc2fce77d..91988d961bc 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-helpers.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-helpers.ts @@ -55,12 +55,13 @@ export function createAgent( parsedNodeTypes: INodeTypeDescription[], llm: BaseChatModel, tracer?: LangChainTracer, + checkpointer?: MemorySaver, ): WorkflowBuilderAgent { return new WorkflowBuilderAgent({ parsedNodeTypes, llmSimpleTask: llm, llmComplexTask: llm, - checkpointer: new MemorySaver(), + checkpointer: checkpointer ?? new MemorySaver(), tracer, }); } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-reporter.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-reporter.ts index 2cf1400c0bf..b63b84241d3 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-reporter.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/evaluation-reporter.ts @@ -1,6 +1,7 @@ import Table from 'cli-table3'; import pc from 'picocolors'; +import { aggregateCacheStats, formatCacheStats } from './cache-analyzer.js'; import { formatColoredScore, formatHeader, @@ -10,7 +11,7 @@ import { formatViolationType, } from './evaluation-helpers.js'; import type { Violation } from '../types/evaluation.js'; -import type { TestResult } from '../types/test-result.js'; +import type { TestResult, CacheStatistics } from '../types/test-result.js'; /** * Generates a markdown report from evaluation results @@ -30,6 +31,13 @@ export function generateMarkdownReport( ): string { const { totalTests, successfulTests, averageScore, categoryAverages, violationCounts } = metrics; + // Calculate aggregate cache statistics + const cacheStats = results + .map((r) => r.cacheStats) + .filter((r): r is CacheStatistics => r !== undefined); + + const aggregateCache = cacheStats.length > 0 ? aggregateCacheStats(cacheStats) : null; + let report = `# AI Workflow Builder Evaluation Report ## Summary @@ -49,7 +57,23 @@ export function generateMarkdownReport( - Major: ${violationCounts.major} - Minor: ${violationCounts.minor} -## Detailed Results +`; + + // Add cache statistics section if available + if (aggregateCache) { + const formatted = formatCacheStats(aggregateCache); + report += `## Prompt Caching Statistics +- Input Tokens: ${formatted.inputTokens} +- Output Tokens: ${formatted.outputTokens} +- Cache Creation Tokens: ${formatted.cacheCreationTokens} +- Cache Read Tokens: ${formatted.cacheReadTokens} +- Cache Hit Rate: ${formatted.cacheHitRate} +- Estimated Cost Savings: ${formatted.costSavings} + +`; + } + + report += `## Detailed Results `; @@ -59,9 +83,18 @@ export function generateMarkdownReport( - **Generation Time**: ${result.generationTime}ms - **Nodes Generated**: ${result.generatedWorkflow.nodes.length} - **Summary**: ${result.evaluationResult.summary} - `; + // Add cache stats for this test if available + if (result.cacheStats) { + const formatted = formatCacheStats(result.cacheStats); + report += `- **Cache Hit Rate**: ${formatted.cacheHitRate} +- **Cost Savings**: ${formatted.costSavings} +`; + } + + report += '\n'; + if ( result.evaluationResult.criticalIssues && result.evaluationResult.criticalIssues.length > 0 @@ -145,7 +178,7 @@ export function displayTestResults( * @param metrics - Calculated metrics */ export function displaySummaryTable( - _results: TestResult[], + results: TestResult[], metrics: { totalTests: number; successfulTests: number; @@ -223,6 +256,73 @@ export function displaySummaryTable( console.log(); console.log(formatHeader('Summary', 70)); console.log(summaryTable.toString()); + + // Display cache statistics if available + displayCacheStatistics(results); +} + +/** + * Displays cache statistics table + * @param results - Array of test results + */ +export function displayCacheStatistics(results: TestResult[]): void { + const cacheStats = results + .map((r) => r.cacheStats) + .filter((r): r is CacheStatistics => r !== undefined); + + if (cacheStats.length === 0) return; + + const aggregateCache = aggregateCacheStats(cacheStats); + const formatted = formatCacheStats(aggregateCache); + + const cacheTable = new Table({ + head: ['Cache Metric', 'Value'], + style: { head: ['cyan'] }, + }); + + // Determine cache quality color + const hitRateColor = + aggregateCache.cacheHitRate > 0.6 + ? pc.green + : aggregateCache.cacheHitRate > 0.3 + ? pc.yellow + : pc.red; + + const savingsColor = + aggregateCache.estimatedCostSavings > 0.01 + ? pc.green + : aggregateCache.estimatedCostSavings > 0.001 + ? pc.yellow + : pc.dim; + + cacheTable.push( + ['Input Tokens', formatted.inputTokens], + ['Output Tokens', formatted.outputTokens], + ['Cache Creation', formatted.cacheCreationTokens], + ['Cache Read', formatted.cacheReadTokens], + [pc.dim('─'.repeat(20)), pc.dim('─'.repeat(20))], + ['Cache Hit Rate', hitRateColor(formatted.cacheHitRate)], + ['Cost Savings', savingsColor(formatted.costSavings)], + ); + + console.log(); + console.log(formatHeader('Prompt Caching Statistics', 70)); + console.log(cacheTable.toString()); + + // Add interpretation + if (aggregateCache.cacheHitRate > 0.6) { + console.log( + pc.green('\n ✓ Excellent cache performance! High hit rate indicates effective caching.'), + ); + } else if (aggregateCache.cacheHitRate > 0.3) { + console.log( + pc.yellow('\n ⚠ Moderate cache performance. Consider optimizing cache control markers.'), + ); + } else if (aggregateCache.cacheHitRate > 0) { + console.log( + pc.red('\n ✗ Low cache hit rate. Review cache control configuration and prompt structure.'), + ); + } } /** diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json index 5b770cca215..a23f0c1726b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/package.json +++ b/packages/@n8n/ai-workflow-builder.ee/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/ai-workflow-builder", - "version": "0.25.0", + "version": "0.26.0", "scripts": { "clean": "rimraf dist .turbo", "typecheck": "tsc --noEmit", @@ -22,7 +22,8 @@ "deps:all": "pnpm run deps:graph && pnpm run deps:graph:service && pnpm run deps:graph:tools && pnpm run deps:circular && pnpm run deps:report", "eval": "tsx evaluations", "eval:langsmith": "USE_LANGSMITH_EVAL=true tsx evaluations", - "eval:generate": "GENERATE_TEST_CASES=true tsx evaluations" + "eval:generate": "GENERATE_TEST_CASES=true tsx evaluations", + "eval:cache": "tsx evaluations/test-cache-quality.ts" }, "main": "dist/index.js", "module": "src/index.ts", diff --git a/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts b/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts index dba42c7f803..27ac900ebea 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts @@ -148,7 +148,10 @@ export class AiWorkflowBuilderService { }) .filter( (nodeType): nodeType is INodeTypeDescription => - nodeType !== undefined && nodeType.hidden !== true, + // We filter out hidden nodes, except for the Data Table node which has custom hiding logic + // See more details in DataTable.node.ts#L29 + nodeType !== undefined && + (nodeType.hidden !== true || nodeType.name === 'n8n-nodes-base.dataTable'), ) .map((nodeType, _index, nodeTypes: INodeTypeDescription[]) => { // If the node type is a tool, we need to find the corresponding non-tool node type diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/core-instructions.ts b/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/core-instructions.ts index 48a95cafe86..5853777878d 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/core-instructions.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/core-instructions.ts @@ -21,4 +21,5 @@ You will receive: 7. HANDLE NESTED PARAMETERS: Correctly update nested structures like headers, conditions, etc. 8. SIMPLE VALUES: For simple parameter updates like "Set X to Y", directly set the parameter without unnecessary nesting 9. GENERATE IDS: When adding new items to arrays (like assignments, headers, etc.), generate unique IDs using a simple pattern like "id-1", "id-2", etc. -10. TOOL NODE DETECTION: Check if node type ends with "Tool" to determine if $fromAI expressions are available`; +10. TOOL NODE DETECTION: Check if node type ends with "Tool" to determine if $fromAI expressions are available +11. PLACEHOLDER FORMAT: When changes specify a placeholder, copy it exactly as "<__PLACEHOLDER_VALUE__VALUE_LABEL__>" (no extra quotes or expressions) and keep VALUE_LABEL descriptive for the user`; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/constants.ts b/packages/@n8n/ai-workflow-builder.ee/src/constants.ts index 66b7f7c258b..48bd139166f 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/constants.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/constants.ts @@ -7,27 +7,29 @@ export const MAX_AI_BUILDER_PROMPT_LENGTH = 1000; // characters /** * Token limits for the LLM context window. */ -export const MAX_TOTAL_TOKENS = 200_000; // Total context window size (input + output) -export const MAX_OUTPUT_TOKENS = 16_000; // Reserved tokens for model response -export const MAX_INPUT_TOKENS = MAX_TOTAL_TOKENS - MAX_OUTPUT_TOKENS - 10_000; // Available tokens for input (with some buffer to account for estimation errors) +export const MAX_TOTAL_TOKENS = 200_000; +export const MAX_OUTPUT_TOKENS = 16_000; +export const MAX_INPUT_TOKENS = MAX_TOTAL_TOKENS - MAX_OUTPUT_TOKENS - 5_000; /** * Maximum length of individual parameter value that can be retrieved via tool call. * Prevents tool responses from becoming too large and filling up the context. */ -export const MAX_PARAMETER_VALUE_LENGTH = 30_000; // Maximum length of individual parameter value that can be retrieved via tool call +export const MAX_PARAMETER_VALUE_LENGTH = 30_000; /** * Token threshold for automatically compacting conversation history. * When conversation exceeds this limit, older messages are summarized to free up space. + * Set to 150k tokens to provide a safety margin before hitting the MAX_INPUT_TOKENS limit. + * This includes all token types: input, output, cache_creation, and cache_read tokens. */ -export const DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS = 20_000; // Tokens threshold for auto-compacting the conversation +export const DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS = MAX_TOTAL_TOKENS - 50_000; /** * Maximum token count for workflow JSON after trimming. * Used to determine when a workflow is small enough to include in context. */ -export const MAX_WORKFLOW_LENGTH_TOKENS = 30_000; // Tokens +export const MAX_WORKFLOW_LENGTH_TOKENS = 30_000; /** * Average character-to-token ratio for Anthropic models. diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts index 0f4c2d18c1b..69b57d85d36 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts @@ -341,14 +341,18 @@ When modifying existing nodes: When unsure about specific values: - Add nodes and connections confidently -- For uncertain parameters, use update_node_parameters with clear placeholders +- For uncertain parameters, use update_node_parameters with placeholders formatted exactly as "<__PLACEHOLDER_VALUE__VALUE_LABEL__>" +- Make VALUE_LABEL descriptive (e.g., "API endpoint URL", "Auth token header") so users know what to supply - For tool nodes with dynamic values, use $fromAI expressions instead of placeholders - Always mention what needs user to configure in the setup response Example for regular nodes: update_node_parameters({{ nodeId: "httpRequest1", - instructions: ["Set URL to YOUR_API_ENDPOINT", "Add your authentication headers"] + instructions: [ + "Set URL to <__PLACEHOLDER_VALUE__API endpoint URL__>", + "Add header Authorization: <__PLACEHOLDER_VALUE__Bearer token__>" + ] }}) Example for tool nodes: @@ -401,25 +405,6 @@ ABSOLUTELY FORBIDDEN IN BUILDING MODE: `; -const currentWorkflowJson = ` - -{workflowJSON} - - -Note: Large property values of the nodes in the workflow JSON above may be trimmed to fit within token limits. -Use get_node_parameter tool to get full details when needed. -`; - -const currentExecutionData = ` - -{executionData} -`; - -const currentExecutionNodesSchemas = ` - -{executionSchema} -`; - const previousConversationSummary = ` {previousSummary} @@ -432,28 +417,14 @@ export const mainAgentPrompt = ChatPromptTemplate.fromMessages([ { type: 'text', text: systemPrompt, - cache_control: { type: 'ephemeral' }, }, { type: 'text', text: instanceUrlPrompt, }, - { - type: 'text', - text: currentWorkflowJson, - }, - { - type: 'text', - text: currentExecutionData, - }, - { - type: 'text', - text: currentExecutionNodesSchemas, - }, { type: 'text', text: responsePatterns, - cache_control: { type: 'ephemeral' }, }, { type: 'text', diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/README.md b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/README.md new file mode 100644 index 00000000000..49c4926e4a6 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/README.md @@ -0,0 +1,287 @@ +# Anthropic Prompt Caching Strategy Visualization + +## Message Stack Structure + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SYSTEM MESSAGE │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Agent Instructions & Node Schemas │ │ +│ │ Instance URL │ │ +│ │ Response Patterns │ │ +│ │ Previous Conversation Summary (if compacted) │ │ +│ │ │ │ +│ │ cache_control: { type: 'ephemeral' } ◄── BREAKPOINT 1 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CONVERSATION MESSAGES │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [0] SYSTEM (from above - cached via Breakpoint 1) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [1] USER: "Create a workflow to send emails" │ │ +│ │ (no workflow context, no cache marker) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [2] ASSISTANT: │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [3] TOOL: "Node added successfully" │ │ +│ │ (no workflow context, no cache marker) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [4] ASSISTANT: │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [5] TOOL: "Node added successfully" │ │ ◄── ITERATION 3 +│ │ (no workflow context, no cache marker) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [6] ASSISTANT: "Workflow ready!" │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [7] USER: "Add error handling" │ │ ◄── ITERATION 4 +│ │ cache_control: { type: 'ephemeral' } ◄── BP 3 │ │ (current: second-to-last) +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [8] ASSISTANT: │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ [9] TOOL: "What about email validation?" │ │ ◄── ITERATION 5 (current) +│ │ + │ │ +│ │ + │ │ +│ │ + │ │ +│ │ cache_control: { type: 'ephemeral' } ◄── BP 4 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Cache Strategy Per Iteration + +### ITERATION 1: First Request +``` +Messages: [0: SYSTEM, 1: USER] + +┌──────────────────────┐ +│ [0] SYSTEM │ ← ✓ Written to cache (BP1) +├──────────────────────┤ +│ [1] USER + WORKFLOW │ ← ✓ Written to cache (BP4) +└──────────────────────┘ + +Cache writes: BP1 (system) + BP4 (last message) +Cache reads: None (first time) +``` + +### ITERATION 2: Second Request +``` +Messages: [0: SYSTEM, 1: USER, 2: ASSISTANT, 3: TOOL] + +BEFORE cleanup: +┌──────────────────────┐ +│ [0] SYSTEM │ ← Already cached +├──────────────────────┤ +│ [1] USER + WORKFLOW │ ← Has OLD workflow + cache marker (needs cleaning) +├──────────────────────┤ +│ [2] ASSISTANT │ +├──────────────────────┤ +│ [3] TOOL │ ← NEW (will get workflow) +└──────────────────────┘ + +AFTER cleanup & marker application: +┌──────────────────────┐ +│ [0] SYSTEM │ ← ✓ Cache HIT (BP1) +├──────────────────────┤ +│ [1] USER │ ← ✓ Cache HIT (workflow removed, BP4 marker removed) +├──────────────────────┤ +│ [2] ASSISTANT │ ← ✓ Cache HIT +├──────────────────────┤ +│ [3] TOOL + WORKFLOW │ ← ✗ Cache MISS (BP4 - new workflow) +└──────────────────────┘ + +Cache reads: BP1 (system) + partial history +Cache writes: BP4 (last message with new workflow) +``` + +### ITERATION 3: Third Request +``` +Messages: [0: SYSTEM, 1: USER, 2: ASST, 3: TOOL, 4: ASST, 5: TOOL] + +BEFORE cleanup: +┌──────────────────────┐ +│ [0] SYSTEM │ ← Cached +├──────────────────────┤ +│ [1] USER │ ← Clean (no workflow) +├──────────────────────┤ +│ [2] ASSISTANT │ +├──────────────────────┤ +│ [3] TOOL + WORKFLOW │ ← Has OLD workflow + cache marker (needs cleaning) +├──────────────────────┤ +│ [4] ASSISTANT │ +├──────────────────────┤ +│ [5] TOOL │ ← NEW (will get workflow) +└──────────────────────┘ + +AFTER cleanup & marker application: +┌──────────────────────┐ +│ [0] SYSTEM │ ← ✓ Cache HIT (BP1) +├──────────────────────┤ +│ [1] USER │ ← ✓ Cache HIT (messages 1-2 cached together) +├──────────────────────┤ +│ [2] ASSISTANT │ ← ✓ Cache HIT +├──────────────────────┤ +│ [3] TOOL │ ← ✓ Cache HIT (workflow + BP4 marker removed) +│ │ → Anthropic finds prefix match! +├──────────────────────┤ +│ [4] ASSISTANT │ ← ✗ Cache MISS (new content) +├──────────────────────┤ +│ [5] TOOL + WORKFLOW │ ← ✗ Cache MISS (BP4 - new workflow) +│ [BP4 marker] │ +└──────────────────────┘ + +Cache reads: BP1 (system) + messages 1-3 +Cache writes: BP4 (last message) +``` + +### ITERATION 5: Long Conversation +``` +Messages: [0: SYS, 1: U, 2: A, 3: T, 4: A, 5: T, 6: A, 7: U, 8: A, 9: T] + +BEFORE cleanup: +┌──────────────────────┐ +│ [0] SYSTEM │ +├──────────────────────┤ +│ [1] USER │ ← No workflow, no marker +├──────────────────────┤ +│ [2] ASSISTANT │ +├──────────────────────┤ +│ [3] TOOL │ ← No workflow, no marker +├──────────────────────┤ +│ [4] ASSISTANT │ +├──────────────────────┤ +│ [5] TOOL │ ← No workflow, no marker +├──────────────────────┤ +│ [6] ASSISTANT │ +├──────────────────────┤ +│ [7] USER + BP3 │ ← Has OLD BP3 marker (will be removed) +├──────────────────────┤ +│ [8] ASSISTANT │ +├──────────────────────┤ +│ [9] TOOL + WF + BP4 │ ← Has OLD workflow + BP4 (both will be removed) +└──────────────────────┘ + +AFTER cleanup & marker application: +┌──────────────────────┐ +│ [0] SYSTEM │ ← ✓ Cache HIT (BP1) +├──────────────────────┤ +│ [1] USER │ ← ✓ Cache HIT (prefix match) +├──────────────────────┤ +│ [2] ASSISTANT │ ← ✓ Cache HIT +├──────────────────────┤ +│ [3] TOOL │ ← ✓ Cache HIT +├──────────────────────┤ +│ [4] ASSISTANT │ ← ✓ Cache HIT +├──────────────────────┤ +│ [5] TOOL │ ← ✓ Cache HIT +├──────────────────────┤ +│ [6] ASSISTANT │ ← ✓ Cache HIT +├──────────────────────┤ +│ [7] USER │ ← ✓ Cache HIT (BP3 marker removed, but still matches) +│ [BP3 marker] │ ← ✓ NEW BP3 marker added (conversation history) +├──────────────────────┤ +│ [8] ASSISTANT │ ← ✗ Cache MISS (new turn) +├──────────────────────┤ +│ [9] TOOL + WORKFLOW │ ← ✗ Cache MISS (BP4 - new workflow state) +│ [BP4 marker] │ +└──────────────────────┘ + +Cache reads: BP1 + messages 1-7 (85% of prompt) +Cache writes: BP3 (second-to-last) + BP4 (last with new workflow) +``` + +## Key Insights + +### 1. **The "Sliding Window" Pattern** +``` +Iteration N-1: Iteration N: Iteration N+1: + +Messages 1-5: clean Messages 1-5: clean Messages 1-7: clean +Message 7: [BP3] → Message 7: clean → Message 9: [BP3] +Message 9: [BP4+WF] Message 9: [BP3] Message 11: [BP4+WF] + Message 11: [BP4+WF] +``` + +The markers "slide forward" through the conversation, always marking the last 2 user/tool messages. + +### 2. **Workflow Context Lifecycle** +``` +┌─────────────┐ +│ NEW REQUEST │ +└──────┬──────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Strip workflow from old messages│ ← cleanStaleWorkflowContext() +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Add workflow to LAST message │ ← applyCacheControlMarkers() +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Mark last 2 messages with cache │ +│ BP3: conversation history │ +│ BP4: current workflow state │ +└──────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Send to Anthropic │ +│ → Automatic prefix matching │ +│ → Cache hits on clean messages │ +│ → Cache miss only on BP4 │ +└─────────────────────────────────┘ +``` + +### 3. **Cache Hit Visualization** +``` +┌────────────────────────────────────────────────────────────┐ +│ ANTHROPIC'S VIEW │ +│ │ +│ Previous Request: Current Request: │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ System [BP1] │ ═══ │ System [BP1] │ ✓ HIT │ +│ │ User msg 1 │ ═══ │ User msg 1 │ ✓ HIT │ +│ │ Tool msg 1 │ ═══ │ Tool msg 1 │ ✓ HIT │ +│ │ User msg 2 │ ═══ │ User msg 2 │ ✓ HIT │ +│ │ Tool msg 2 [BP3]│ ═══ │ Tool msg 2 │ ✓ HIT │ +│ │ User+WF [BP4] │ │ Tool msg 3 [BP3]│ ✗ MISS │ +│ └─────────────────┘ │ User+WF [BP4] │ ✗ MISS │ +│ └─────────────────┘ │ +│ │ +│ Cache reuses: System + msg1-4 (lines match!) │ +│ Cache writes: New BP3 + BP4 │ +└────────────────────────────────────────────────────────────┘ +``` + +## Result +- ✓ System message: 100% hit (never changes) +- ✓ Old messages: ~100% hit (workflow stripped, prefixes match) +- ✓ Second-to-last: ~85% hit (conversation history cached) +- ✗ Last message: ~0% hit (workflow changes every time) + +Only the last message with the fresh workflow state is a cache miss! diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/__tests__/helpers.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/__tests__/helpers.test.ts new file mode 100644 index 00000000000..256bd48e0ee --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/__tests__/helpers.test.ts @@ -0,0 +1,457 @@ +import { HumanMessage, ToolMessage, AIMessage } from '@langchain/core/messages'; +import type { BaseMessage } from '@langchain/core/messages'; + +import { + findUserToolMessageIndices, + cleanStaleWorkflowContext, + applyCacheControlMarkers, +} from '../helpers'; + +describe('Cache Control Helpers', () => { + describe('findUserToolMessageIndices', () => { + it('should return empty array for empty messages', () => { + const messages: BaseMessage[] = []; + const result = findUserToolMessageIndices(messages); + + expect(result).toEqual([]); + }); + + it('should find single HumanMessage index', () => { + const messages = [new AIMessage('system'), new HumanMessage('user message')]; + + const result = findUserToolMessageIndices(messages); + + expect(result).toEqual([1]); + }); + + it('should find multiple HumanMessage and ToolMessage indices', () => { + const messages = [ + new AIMessage('system'), + new HumanMessage('user 1'), + new AIMessage('assistant 1'), + new ToolMessage({ content: 'tool result', tool_call_id: '1' }), + new AIMessage('assistant 2'), + new HumanMessage('user 2'), + ]; + + const result = findUserToolMessageIndices(messages); + + expect(result).toEqual([1, 3, 5]); + }); + + it('should handle only AIMessages (no user/tool messages)', () => { + const messages = [ + new AIMessage('assistant 1'), + new AIMessage('assistant 2'), + new AIMessage('assistant 3'), + ]; + + const result = findUserToolMessageIndices(messages); + + expect(result).toEqual([]); + }); + + it('should find consecutive ToolMessages', () => { + const messages = [ + new HumanMessage('user'), + new ToolMessage({ content: 'tool 1', tool_call_id: '1' }), + new ToolMessage({ content: 'tool 2', tool_call_id: '2' }), + new ToolMessage({ content: 'tool 3', tool_call_id: '3' }), + ]; + + const result = findUserToolMessageIndices(messages); + + expect(result).toEqual([0, 1, 2, 3]); + }); + }); + + describe('cleanStaleWorkflowContext', () => { + const createWorkflowContext = () => ` + +{"nodes": []} + + +{"data": "test"} + + +[{"type": "test"}] +`; + + it('should do nothing for empty indices', () => { + const messages = [new HumanMessage('test')]; + const originalContent = messages[0].content; + + cleanStaleWorkflowContext(messages, []); + + expect(messages[0].content).toBe(originalContent); + }); + + it('should do nothing when only one user/tool message exists', () => { + const messages = [new AIMessage('system'), new HumanMessage('user message')]; + const originalContent = messages[1].content; + + cleanStaleWorkflowContext(messages, [1]); + + expect(messages[1].content).toBe(originalContent); + }); + + it('should remove workflow context from string content in old messages', () => { + const workflowContext = createWorkflowContext(); + const messages = [ + new HumanMessage(`First message${workflowContext}`), + new HumanMessage('Second message'), + ]; + + cleanStaleWorkflowContext(messages, [0, 1]); + + expect(messages[0].content).toBe('First message'); + expect(messages[1].content).toBe('Second message'); + }); + + it('should not remove workflow context from the last message', () => { + const workflowContext = createWorkflowContext(); + const messages = [ + new HumanMessage(`First message${workflowContext}`), + new HumanMessage(`Last message${workflowContext}`), + ]; + + cleanStaleWorkflowContext(messages, [0, 1]); + + expect(messages[0].content).toBe('First message'); + expect(messages[1].content).toBe(`Last message${workflowContext}`); + }); + + it('should remove cache_control markers from array content blocks', () => { + const message0 = new HumanMessage('message 1'); + // Manually set array content as it would be after applyCacheControlMarkers + message0.content = [ + { + type: 'text' as const, + text: 'message 1', + cache_control: { type: 'ephemeral' as const }, + }, + ]; + const messages = [message0, new HumanMessage('message 2')]; + + cleanStaleWorkflowContext(messages, [0, 1]); + + const content = messages[0].content as Array<{ + type: string; + text: string; + cache_control?: unknown; + }>; + expect(content[0].cache_control).toBeUndefined(); + }); + + it('should handle mixed string and array content', () => { + const workflowContext = createWorkflowContext(); + const message1 = new HumanMessage('Array message'); + message1.content = [ + { + type: 'text' as const, + text: 'Array message', + cache_control: { type: 'ephemeral' as const }, + }, + ]; + + const messages = [ + new HumanMessage(`String message${workflowContext}`), + message1, + new HumanMessage('Last message'), + ]; + + cleanStaleWorkflowContext(messages, [0, 1, 2]); + + expect(messages[0].content).toBe('String message'); + const content1 = messages[1].content as Array<{ + cache_control?: unknown; + }>; + expect(content1[0].cache_control).toBeUndefined(); + }); + + it('should handle multiple old messages with workflow context', () => { + const workflowContext = createWorkflowContext(); + const messages = [ + new HumanMessage(`Message 1${workflowContext}`), + new ToolMessage({ content: `Tool 1${workflowContext}`, tool_call_id: '1' }), + new HumanMessage(`Message 2${workflowContext}`), + new HumanMessage('Last message'), + ]; + + cleanStaleWorkflowContext(messages, [0, 1, 2, 3]); + + expect(messages[0].content).toBe('Message 1'); + expect(messages[1].content).toBe('Tool 1'); + expect(messages[2].content).toBe('Message 2'); + expect(messages[3].content).toBe('Last message'); + }); + }); + + describe('applyCacheControlMarkers', () => { + it('should do nothing for empty indices', () => { + const messages = [new HumanMessage('test')]; + const originalContent = messages[0].content; + + applyCacheControlMarkers(messages, [], 'workflow context'); + + expect(messages[0].content).toBe(originalContent); + }); + + it('should add workflow context to last message when content is string', () => { + const messages = [new HumanMessage('user message')]; + const workflowContext = '\ntest'; + + applyCacheControlMarkers(messages, [0], workflowContext); + + // applyCacheControlMarkers converts string content to array format + const content = messages[0].content as Array<{ + type: string; + text: string; + cache_control?: { type: string }; + }>; + expect(content[0].text).toBe(`user message${workflowContext}`); + expect(content[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should apply cache marker to last message when only one user/tool message', () => { + const messages = [new HumanMessage('user message')]; + + applyCacheControlMarkers(messages, [0], '\n'); + + const content = messages[0].content as Array<{ + type: string; + text: string; + cache_control?: { type: string }; + }>; + expect(content).toHaveLength(1); + expect(content[0].type).toBe('text'); + expect(content[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should apply cache markers to last two messages', () => { + const messages = [new HumanMessage('first message'), new HumanMessage('second message')]; + + applyCacheControlMarkers(messages, [0, 1], '\n'); + + // Check first message (second-to-last) has cache marker + const content0 = messages[0].content as Array<{ + cache_control?: { type: string }; + }>; + expect(content0[0].cache_control).toEqual({ type: 'ephemeral' }); + + // Check last message has cache marker + const content1 = messages[1].content as Array<{ + cache_control?: { type: string }; + }>; + expect(content1[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should handle array content in second-to-last message', () => { + const message0 = new HumanMessage('first message'); + message0.content = [ + { + type: 'text' as const, + text: 'first message', + }, + ]; + const messages = [message0, new HumanMessage('second message')]; + + applyCacheControlMarkers(messages, [0, 1], '\n'); + + const content0 = messages[0].content as Array<{ + type: string; + text: string; + cache_control?: { type: string }; + }>; + expect(content0[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should handle array content in last message', () => { + const message1 = new HumanMessage('second message'); + message1.content = [ + { + type: 'text' as const, + text: 'second message', + }, + ]; + const messages = [new HumanMessage('first message'), message1]; + + applyCacheControlMarkers(messages, [0, 1], '\n'); + + const content1 = messages[1].content as Array<{ + cache_control?: { type: string }; + }>; + expect(content1[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should apply markers to correct messages in long conversation', () => { + const messages = [ + new HumanMessage('msg 1'), + new HumanMessage('msg 2'), + new HumanMessage('msg 3'), + new HumanMessage('msg 4'), + new HumanMessage('msg 5'), + ]; + + applyCacheControlMarkers(messages, [0, 1, 2, 3, 4], '\n'); + + // Messages 0-2 should not have cache markers + expect(typeof messages[0].content).toBe('string'); + expect(typeof messages[1].content).toBe('string'); + expect(typeof messages[2].content).toBe('string'); + + // Message 3 (second-to-last) should have cache marker + const content3 = messages[3].content as Array<{ + cache_control?: { type: string }; + }>; + expect(content3[0].cache_control).toEqual({ type: 'ephemeral' }); + + // Message 4 (last) should have cache marker + const content4 = messages[4].content as Array<{ + cache_control?: { type: string }; + }>; + expect(content4[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should not modify workflow context when message has array content', () => { + const message0 = new HumanMessage('existing content'); + message0.content = [ + { + type: 'text' as const, + text: 'existing content', + }, + ]; + const messages = [message0]; + const workflowContext = '\nnew'; + + applyCacheControlMarkers(messages, [0], workflowContext); + + // Workflow context is only added to string content, not array content + const content = messages[0].content as Array<{ text: string }>; + expect(content[0].text).toBe('existing content'); + }); + + it('should handle ToolMessages correctly', () => { + const messages = [ + new ToolMessage({ content: 'tool result 1', tool_call_id: '1' }), + new ToolMessage({ content: 'tool result 2', tool_call_id: '2' }), + ]; + + applyCacheControlMarkers(messages, [0, 1], '\n'); + + // Both should have cache markers applied + const content0 = messages[0].content as unknown as Array<{ + cache_control?: { type: string }; + }>; + const content1 = messages[1].content as unknown as Array<{ + cache_control?: { type: string }; + }>; + + expect(content0[0].cache_control).toEqual({ type: 'ephemeral' }); + expect(content1[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + }); + + describe('Integration: Full cache control flow', () => { + it('should correctly implement sliding window pattern', () => { + const workflowContext = '\ncurrent state'; + + // Iteration 1: First request + const messages1 = [new HumanMessage('Create a workflow')]; + + applyCacheControlMarkers(messages1, [0], workflowContext); + + expect((messages1[0].content as Array<{ cache_control?: unknown }>)[0].cache_control).toEqual( + { type: 'ephemeral' }, + ); + + // Iteration 2: Add assistant response and new user message + // Simulate what would happen: first message was converted to array by applyCacheControlMarkers + const message2_0 = new HumanMessage('Create a workflow'); + message2_0.content = [ + { + type: 'text' as const, + text: 'Create a workflow' + workflowContext, + cache_control: { type: 'ephemeral' as const }, + }, + ]; + const messages2 = [message2_0, new AIMessage('Done'), new HumanMessage('Add email node')]; + + const indices2 = findUserToolMessageIndices(messages2); + cleanStaleWorkflowContext(messages2, indices2); + applyCacheControlMarkers(messages2, indices2, workflowContext); + + // First message: workflow stays in array content (only string content gets cleaned) + // AND it gets a cache marker again because it's now the second-to-last message! + const content0 = messages2[0].content as Array<{ text: string; cache_control?: unknown }>; + expect(content0[0].text).toContain('Create a workflow'); + expect(content0[0].text).toContain('current state'); // Workflow stays + expect(content0[0].cache_control).toEqual({ type: 'ephemeral' }); // Gets marker as second-to-last + + // Last message should have new workflow and cache marker + expect(typeof messages2[2].content).not.toBe('string'); + const content2 = messages2[2].content as Array<{ text: string; cache_control?: unknown }>; + expect(content2[0].text).toContain('workflow>current state'); + expect(content2[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should handle complete conversation lifecycle', () => { + const workflowV1 = '\nversion 1'; + const workflowV2 = '\nversion 2'; + const workflowV3 = '\nversion 3'; + + // Start conversation + const messages = [new HumanMessage('msg 1'), new AIMessage('response 1')]; + + let indices = findUserToolMessageIndices(messages); + applyCacheControlMarkers(messages, indices, workflowV1); + + // First message is now array format after applyCacheControlMarkers + const content0Initial = messages[0].content as Array<{ text: string }>; + expect(content0Initial[0].text).toContain('msg 1'); + expect(content0Initial[0].text).toContain('version 1'); + + // Add second turn - simulate realistic state where message[0] is now array + messages.push(new HumanMessage('msg 2')); + indices = findUserToolMessageIndices(messages); + cleanStaleWorkflowContext(messages, indices); + applyCacheControlMarkers(messages, indices, workflowV2); + + // Verify first message: workflow stays in array content (only string content gets cleaned) + // AND it gets a cache marker again because with 2 user messages, it's the second-to-last! + const content0WithMarker = messages[0].content as Array<{ + text: string; + cache_control?: { type: string }; + }>; + expect(content0WithMarker[0].text).toContain('msg 1'); + expect(content0WithMarker[0].text).toContain('version 1'); // Workflow stays in array content + expect(content0WithMarker[0].cache_control).toEqual({ type: 'ephemeral' }); // Gets marker as second-to-last + + // Add third turn + messages.push(new AIMessage('response 2')); + messages.push(new HumanMessage('msg 3')); + indices = findUserToolMessageIndices(messages); + cleanStaleWorkflowContext(messages, indices); + applyCacheControlMarkers(messages, indices, workflowV3); + + // Verify old messages: workflow stays in array content, but cache markers move + const content0NoMarker = messages[0].content as Array<{ + text: string; + cache_control?: { type: string }; + }>; + expect(content0NoMarker[0].text).toContain('msg 1'); + expect(content0NoMarker[0].cache_control).toBeUndefined(); // Old marker removed + + const content2 = messages[2].content as Array<{ + text: string; + cache_control?: { type: string }; + }>; + expect(content2[0].text).toContain('msg 2'); + expect(content2[0].cache_control).toEqual({ type: 'ephemeral' }); // Second-to-last gets marker + + // Verify last message has cache marker + const lastContent = messages[4].content as Array<{ cache_control?: unknown }>; + expect(lastContent[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/helpers.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/helpers.ts new file mode 100644 index 00000000000..83d52ade894 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/helpers.ts @@ -0,0 +1,162 @@ +import { HumanMessage, ToolMessage } from '@langchain/core/messages'; +import type { BaseMessage } from '@langchain/core/messages'; + +/** + * Type guard to check if a content block is a text block that can have cache_control. + * This allows us to safely add Anthropic's cache control markers to message content blocks. + */ +function isTextBlock( + block: unknown, +): block is { text: string; cache_control?: { type: 'ephemeral' } } { + return ( + typeof block === 'object' && + block !== null && + 'text' in block && + typeof (block as { text: unknown }).text === 'string' + ); +} + +/** + * Type guard to check if a block has cache_control property. + */ +function hasCacheControl(block: unknown): block is { cache_control?: { type: 'ephemeral' } } { + return typeof block === 'object' && block !== null && 'cache_control' in block; +} + +/** + * Finds the indices of all HumanMessage and ToolMessage instances in the messages array. + * These indices are used to identify cache breakpoints for Anthropic's prompt caching. + * + * @param messages - Array of LangChain messages + * @returns Array of indices where user or tool messages appear + */ +export function findUserToolMessageIndices(messages: BaseMessage[]): number[] { + const userToolIndices: number[] = []; + for (let i = 0; i < messages.length; i++) { + if (messages[i] instanceof HumanMessage || messages[i] instanceof ToolMessage) { + userToolIndices.push(i); + } + } + + return userToolIndices; +} + +/** + * Removes stale workflow context from all messages except the last one. + * This prevents Anthropic from caching outdated workflow state. + * + * The workflow context includes: + * - ... + * - ... + * - ... + * + * Also removes any existing cache_control markers from old messages to ensure + * only the most recent messages are cached. + * + * @param messages - Array of LangChain messages to clean + * @param userToolIndices - Indices of user/tool messages (from findUserToolMessageIndices) + */ +export function cleanStaleWorkflowContext( + messages: BaseMessage[], + userToolIndices: number[], +): void { + if (userToolIndices.length === 0) { + return; + } + + // Clean all messages except the last one + for (let i = 0; i < userToolIndices.length - 1; i++) { + const idx = userToolIndices[i]; + const message = messages[idx]; + + // Remove workflow context from string content + if (typeof message.content === 'string') { + message.content = message.content.replace( + /\n*[\s\S]*?<\/current_execution_nodes_schemas>/, + '', + ); + } + + // Remove cache_control markers from array content blocks + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (hasCacheControl(block)) { + delete block.cache_control; + } + } + } + } +} + +/** + * Applies Anthropic's prompt caching optimization by: + * 1. Adding the current workflow context to the last user/tool message + * 2. Marking the last two user/tool messages with cache_control markers + * + * This strategy leverages Anthropic's prompt caching by caching: + * - The conversation history (second-to-last message) + * - The current workflow state (last message) + * + * Anthropic caches content blocks marked with { cache_control: { type: 'ephemeral' } }, + * allowing subsequent API calls to reuse cached prompts and reduce token costs. + * + * @param messages - Array of LangChain messages to modify + * @param userToolIndices - Indices of user/tool messages (from findUserToolMessageIndices) + * @param workflowContext - Current workflow JSON and execution data to append + */ +export function applyCacheControlMarkers( + messages: BaseMessage[], + userToolIndices: number[], + workflowContext: string, +): void { + if (userToolIndices.length === 0) { + return; + } + + // Add current workflow context to the last user/tool message + const lastIdx = userToolIndices[userToolIndices.length - 1]; + const lastMessage = messages[lastIdx]; + if (typeof lastMessage.content === 'string') { + lastMessage.content = lastMessage.content + workflowContext; + } + + // Mark second-to-last message for caching (conversation history) + if (userToolIndices.length > 1) { + const secondToLastIdx = userToolIndices[userToolIndices.length - 2]; + const secondToLastMessage = messages[secondToLastIdx]; + + if (typeof secondToLastMessage.content === 'string') { + secondToLastMessage.content = [ + { + type: 'text', + text: secondToLastMessage.content, + cache_control: { type: 'ephemeral' }, + }, + ]; + } else if (Array.isArray(secondToLastMessage.content)) { + const lastBlock = secondToLastMessage.content[secondToLastMessage.content.length - 1]; + if (isTextBlock(lastBlock)) { + lastBlock.cache_control = { type: 'ephemeral' }; + } + } + } + + // Mark last message for caching (current workflow state) + const lastUserToolIdx = userToolIndices[userToolIndices.length - 1]; + const lastUserToolMessage = messages[lastUserToolIdx]; + + if (typeof lastUserToolMessage.content === 'string') { + lastUserToolMessage.content = [ + { + type: 'text', + text: lastUserToolMessage.content, + cache_control: { type: 'ephemeral' }, + }, + ]; + } else if (Array.isArray(lastUserToolMessage.content)) { + const lastBlock = lastUserToolMessage.content[lastUserToolMessage.content.length - 1]; + if (isTextBlock(lastBlock)) { + lastBlock.cache_control = { type: 'ephemeral' }; + } + } +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/index.ts new file mode 100644 index 00000000000..e7f95706b67 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/cache-control/index.ts @@ -0,0 +1,14 @@ +/** + * Cache control utilities for optimizing Anthropic prompt caching. + * + * This module implements a 4-breakpoint caching strategy that achieves 80-85% cache hit rates + * by strategically placing cache markers and managing workflow context. + * + * @see README.md for detailed visualization and explanation + */ + +export { + findUserToolMessageIndices, + cleanStaleWorkflowContext, + applyCacheControlMarkers, +} from './helpers'; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/token-usage.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/token-usage.ts index e18c75e3c99..8bf556b5cf0 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/token-usage.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/token-usage.ts @@ -17,6 +17,8 @@ export type AIMessageWithUsageMetadata = AIMessage & { export interface TokenUsage { input_tokens: number; output_tokens: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; } /** diff --git a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts index daa82114ee8..4b1d2e0e3cf 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts @@ -1,6 +1,6 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; -import type { ToolMessage } from '@langchain/core/messages'; import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages'; +import type { ToolMessage } from '@langchain/core/messages'; import type { RunnableConfig } from '@langchain/core/runnables'; import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; import type { MemorySaver } from '@langchain/langgraph'; @@ -28,10 +28,15 @@ import { SessionManagerService } from './session-manager.service'; import { getBuilderTools } from './tools/builder-tools'; import { mainAgentPrompt } from './tools/prompts/main-agent.prompt'; import type { SimpleWorkflow } from './types/workflow'; +import { + applyCacheControlMarkers, + cleanStaleWorkflowContext, + findUserToolMessageIndices, +} from './utils/cache-control/helpers'; import { cleanupDanglingToolCallMessages } from './utils/cleanup-dangling-tool-call-messages'; import { processOperations } from './utils/operations-processor'; import { createStreamProcessor, type BuilderTool } from './utils/stream-processor'; -import { estimateTokenCountFromMessages, extractLastTokenUsage } from './utils/token-usage'; +import { estimateTokenCountFromMessages } from './utils/token-usage'; import { executeToolsInParallel } from './utils/tool-executor'; import { WorkflowState } from './workflow-state'; @@ -114,13 +119,43 @@ export class WorkflowBuilderAgent { }); } + const hasPreviousSummary = state.previousSummary && state.previousSummary !== 'EMPTY'; + const prompt = await mainAgentPrompt.invoke({ ...state, - workflowJSON: trimWorkflowJSON(state.workflowJSON), - executionData: state.workflowContext?.executionData ?? {}, - executionSchema: state.workflowContext?.executionSchema ?? [], instanceUrl: this.instanceUrl, + previousSummary: hasPreviousSummary ? state.previousSummary : '', }); + const trimmedWorkflow = trimWorkflowJSON(state.workflowJSON); + const executionData = state.workflowContext?.executionData ?? {}; + const executionSchema = state.workflowContext?.executionSchema ?? []; + + const workflowContext = [ + '', + '', + JSON.stringify(trimmedWorkflow, null, 2), + '', + '', + 'Note: Large property values of the nodes in the workflow JSON above may be trimmed to fit within token limits.', + 'Use get_node_parameter tool to get full details when needed.', + '', + '', + '', + JSON.stringify(executionData, null, 2), + '', + '', + '', + JSON.stringify(executionSchema, null, 2), + '', + ].join('\n'); + + // Optimize prompts for Anthropic's caching by: + // 1. Finding all user/tool message positions (cache breakpoints) + // 2. Removing stale workflow context from old messages + // 3. Adding current workflow context and cache markers to recent messages + const userToolIndices = findUserToolMessageIndices(prompt.messages); + cleanStaleWorkflowContext(prompt.messages, userToolIndices); + applyCacheControlMarkers(prompt.messages, userToolIndices, workflowContext); const estimatedTokens = estimateTokenCountFromMessages(prompt.messages); @@ -136,22 +171,14 @@ export class WorkflowBuilderAgent { }; const shouldAutoCompact = ({ messages }: typeof WorkflowState.State) => { - const tokenUsage = extractLastTokenUsage(messages); + // Estimate the current conversation size by counting tokens in all messages + // This is more accurate than using the last API call's token usage, + // because the conversation may have grown since the last call + const estimatedTokens = estimateTokenCountFromMessages(messages); - if (!tokenUsage) { - this.logger?.debug('No token usage metadata found'); - return false; - } + const shouldCompact = estimatedTokens > this.autoCompactThresholdTokens; - const tokensUsed = tokenUsage.input_tokens + tokenUsage.output_tokens; - - this.logger?.debug('Token usage', { - inputTokens: tokenUsage.input_tokens, - outputTokens: tokenUsage.output_tokens, - totalTokens: tokensUsed, - }); - - return tokensUsed > this.autoCompactThresholdTokens; + return shouldCompact; }; const shouldModifyState = (state: typeof WorkflowState.State) => { @@ -230,10 +257,6 @@ export class WorkflowBuilderAgent { const lastHumanMessage = messages[messages.length - 1] satisfies HumanMessage; const isAutoCompact = lastHumanMessage.content !== '/compact'; - this.logger?.debug('Compacting conversation history', { - isAutoCompact, - }); - const compactedMessages = await conversationCompactChain( this.llmSimpleTask, messages, diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index d6726754dbf..06a559840c1 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.49.0", + "version": "0.50.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 4606e8f80c2..efdd53d2d97 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -23,6 +23,7 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record = { export const chatHubConversationModelSchema = z.object({ provider: chatHubProviderSchema, model: z.string(), + workflowId: z.string().nullable().default(null), }); export type ChatHubConversationModel = z.infer; @@ -48,8 +49,10 @@ export type ChatModelsResponse = Record< export class ChatHubSendMessageRequest extends Z.class({ messageId: z.string().uuid(), sessionId: z.string().uuid(), + replyId: z.string().uuid(), message: z.string(), model: chatHubConversationModelSchema, + previousMessageId: z.string().uuid().nullable(), credentials: z.record( z.object({ id: z.string(), @@ -57,3 +60,59 @@ export class ChatHubSendMessageRequest extends Z.class({ }), ), }) {} + +export type ChatHubMessageType = 'human' | 'ai' | 'system' | 'tool' | 'generic'; +export type ChatHubMessageState = 'active' | 'superseded' | 'hidden' | 'deleted'; + +export type ChatSessionId = string; // UUID +export type ChatMessageId = string; // UUID + +export interface ChatHubSessionDto { + id: ChatSessionId; + title: string; + ownerId: string; + lastMessageAt: string | null; + credentialId: string | null; + provider: ChatHubProvider | null; + model: string | null; + workflowId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ChatHubMessageDto { + id: ChatMessageId; + sessionId: ChatSessionId; + type: ChatHubMessageType; + name: string; + content: string; + provider: ChatHubProvider | null; + model: string | null; + workflowId: string | null; + executionId: number | null; + state: ChatHubMessageState; + createdAt: string; + updatedAt: string; + + previousMessageId: ChatMessageId | null; + turnId: ChatMessageId | null; + retryOfMessageId: ChatMessageId | null; + revisionOfMessageId: ChatMessageId | null; + runIndex: number; + + responseIds: ChatMessageId[]; + retryIds: ChatMessageId[]; + revisionIds: ChatMessageId[]; +} + +export type ChatHubConversationsResponse = ChatHubSessionDto[]; + +export interface ChatHubConversationResponse { + session: ChatHubSessionDto; + + conversation: { + messages: Record; + rootIds: string[]; + activeMessageChain: string[]; + }; +} diff --git a/packages/@n8n/api-types/src/dto/ai/ai-session-metadata-response.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-session-metadata-response.dto.ts new file mode 100644 index 00000000000..45d7664c6be --- /dev/null +++ b/packages/@n8n/api-types/src/dto/ai/ai-session-metadata-response.dto.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class AiSessionMetadataResponseDto extends Z.class({ + hasMessages: z.boolean(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 80d524a324e..5f4a92d1f05 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -4,6 +4,7 @@ export { AiBuilderChatRequestDto } from './ai/ai-build-request.dto'; export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto'; export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto'; export { AiSessionRetrievalRequestDto } from './ai/ai-session-retrieval-request.dto'; +export { AiSessionMetadataResponseDto } from './ai/ai-session-metadata-response.dto'; export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto'; export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts index 3c17e873e09..20657a13c5a 100644 --- a/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts @@ -2,21 +2,35 @@ import { ImportWorkflowFromUrlDto } from '../import-workflow-from-url.dto'; describe('ImportWorkflowFromUrlDto', () => { describe('Valid requests', () => { - test('should validate $name', () => { - const result = ImportWorkflowFromUrlDto.safeParse({ + test.each([ + { + name: 'valid URL with .json extension', url: 'https://example.com/workflow.json', - }); + }, + { + name: 'valid URL without .json extension', + url: 'https://example.com/workflow', + }, + { + name: 'valid URL with query parameters', + url: 'https://example.com/workflow.json?param=value', + }, + { + name: 'valid URL with fragments', + url: 'https://example.com/workflow.json#section', + }, + { + name: 'valid API endpoint URL', + url: 'https://api.example.com/v1/workflows/123', + }, + ])('should validate $name', ({ url }) => { + const result = ImportWorkflowFromUrlDto.safeParse({ url }); expect(result.success).toBe(true); }); }); describe('Invalid requests', () => { test.each([ - { - name: 'invalid URL (not ending with .json)', - url: 'https://example.com/workflow', - expectedErrorPath: ['url'], - }, { name: 'invalid URL (missing protocol)', url: 'example.com/workflow.json', @@ -42,14 +56,6 @@ describe('ImportWorkflowFromUrlDto', () => { url: 'not-a-url.json', expectedErrorPath: ['url'], }, - { - name: 'valid URL with query parameters', - url: 'https://example.com/workflow.json?param=value', - }, - { - name: 'valid URL with fragments', - url: 'https://example.com/workflow.json#section', - }, ])('should fail validation for $name', ({ url, expectedErrorPath }) => { const result = ImportWorkflowFromUrlDto.safeParse({ url }); diff --git a/packages/@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto.ts b/packages/@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto.ts index 310e620fde7..91efe304f1d 100644 --- a/packages/@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflows/import-workflow-from-url.dto.ts @@ -2,5 +2,5 @@ import { z } from 'zod'; import { Z } from 'zod-class'; export class ImportWorkflowFromUrlDto extends Z.class({ - url: z.string().url().endsWith('.json'), + url: z.string().url(), }) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 4a1d762bcc4..fbf6d9146e6 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -11,11 +11,18 @@ export { type ChatHubConversationModel, chatHubProviderSchema, type ChatHubProvider, + type ChatHubMessageType, + type ChatHubMessageState, PROVIDER_CREDENTIAL_TYPE_MAP, chatModelsRequestSchema, type ChatModelsRequest, type ChatModelsResponse, ChatHubSendMessageRequest, + type ChatMessageId, + type ChatHubMessageDto, + type ChatHubSessionDto, + type ChatHubConversationResponse, + type ChatHubConversationsResponse, } from './chat-hub'; export type { Collaborator } from './push/collaboration'; diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index e171e555151..8011de81941 100644 --- a/packages/@n8n/backend-common/package.json +++ b/packages/@n8n/backend-common/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-common", - "version": "0.25.0", + "version": "0.26.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-test-utils/MIGRATION_TESTING.md b/packages/@n8n/backend-test-utils/MIGRATION_TESTING.md new file mode 100644 index 00000000000..375954e3e9f --- /dev/null +++ b/packages/@n8n/backend-test-utils/MIGRATION_TESTING.md @@ -0,0 +1,87 @@ +# Migration Testing Helpers + +This package provides utilities for testing database migrations by allowing you to stop before a specific migration, insert test data, and then run that migration. + +## API + +### `initDbUpToMigration(beforeMigrationName: string): Promise` + +Initializes the database and runs all migrations up to (but not including) the specified migration. + +**Parameters:** +- `beforeMigrationName`: The class name of the migration to stop before (e.g., `'AddUserRole1234567890'`) + +**Throws:** +- `UnexpectedError` if the migration is not found or database is not initialized + +### `runSingleMigration(migrationName: string): Promise` + +Runs a single migration by name. + +**Parameters:** +- `migrationName`: The class name of the migration to run (e.g., `'AddUserRole1234567890'`) + +**Throws:** +- `UnexpectedError` if the migration is not found or database is not initialized + +## `undoLastSingleMigration(): Promise` + +Undoes the last single migration. + + +## Usage Example + +```typescript +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { initDbUpToMigration, runSingleMigration } from '@n8n/backend-test-utils'; + +describe('AddUserRole1234567890 Migration', () => { + let dataSource: DataSource; + + beforeAll(async () => { + // Initialize database but stop BEFORE the migration we want to test + await initDbUpToMigration('AddUserRole1234567890'); + dataSource = Container.get(DataSource); + }); + + it('should add role column to users table', async () => { + // Insert test data in the OLD schema (before migration) + // You should not use Repositories, because these will break after schema changes + // over time. + await dataSource.query(` + INSERT INTO users (id, email, password) + VALUES (1, 'test@example.com', 'hashed_password') + `); + + // Run the migration + await runSingleMigration('AddUserRole1234567890'); + + // Verify the migration worked correctly + const users = await dataSource.query('SELECT * FROM users WHERE id = 1'); + expect(users[0].role).toBe('member'); // Default role was added + }); +}); +``` + +## How It Works + +1. **`initDbUpToMigration`**: + - Gets all available migrations from TypeORM DataSource + - Finds the target migration by name + - Temporarily replaces the migrations array with only migrations before the target + - Wraps and runs those migrations + - Restores the full migrations array + +2. **`runSingleMigration`**: + - Finds the specific migration by name + - Temporarily replaces the migrations array with only that migration + - Wraps and runs that single migration + - Restores the full migrations array + +## Important Notes + +- These functions must be used with an initialized database connection (after `dbConnection.init()`) +- Do NOT call `dbConnection.migrate()` before using these helpers - they replace that step +- Migration wrapping is idempotent - migrations won't be double-wrapped +- The full migrations array is always restored after operations complete (even on error) diff --git a/packages/@n8n/backend-test-utils/package.json b/packages/@n8n/backend-test-utils/package.json index dd622abf648..a17cf5f250c 100644 --- a/packages/@n8n/backend-test-utils/package.json +++ b/packages/@n8n/backend-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/backend-test-utils", - "version": "0.18.0", + "version": "0.19.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/backend-test-utils/src/index.ts b/packages/@n8n/backend-test-utils/src/index.ts index 9b168308b18..b098a879bbb 100644 --- a/packages/@n8n/backend-test-utils/src/index.ts +++ b/packages/@n8n/backend-test-utils/src/index.ts @@ -10,3 +10,4 @@ export * as testModules from './test-modules'; export * from './db/workflows'; export * from './db/projects'; export * from './mocking'; +export * from './migration-test-helpers'; diff --git a/packages/@n8n/backend-test-utils/src/migration-test-helpers.ts b/packages/@n8n/backend-test-utils/src/migration-test-helpers.ts new file mode 100644 index 00000000000..a065e43927e --- /dev/null +++ b/packages/@n8n/backend-test-utils/src/migration-test-helpers.ts @@ -0,0 +1,135 @@ +import { GlobalConfig } from '@n8n/config'; +import { type DatabaseType, DbConnection, type Migration } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource, type QueryRunner } from '@n8n/typeorm'; +import { UnexpectedError } from 'n8n-workflow'; + +async function reinitializeDataConnection(): Promise { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + await dbConnection.init(); +} + +/** + * Test migration context with database-specific helpers (similar to MigrationContext). + */ +export interface TestMigrationContext { + queryRunner: QueryRunner; + tablePrefix: string; + dbType: DatabaseType; + isMysql: boolean; + isSqlite: boolean; + isPostgres: boolean; + escape: { + columnName(name: string): string; + tableName(name: string): string; + indexName(name: string): string; + }; +} + +/** + * Create a test migration context with database-specific helpers. + * Provides the same utilities that migrations have access to. + */ +export function createTestMigrationContext(dataSource: DataSource): TestMigrationContext { + const globalConfig = Container.get(GlobalConfig); + const dbType = globalConfig.database.type; + const tablePrefix = globalConfig.database.tablePrefix; + const queryRunner = dataSource.createQueryRunner(); + + return { + queryRunner, + tablePrefix, + dbType, + isMysql: ['mariadb', 'mysqldb'].includes(dbType), + isSqlite: dbType === 'sqlite', + isPostgres: dbType === 'postgresdb', + escape: { + columnName: (name) => queryRunner.connection.driver.escape(name), + tableName: (name) => queryRunner.connection.driver.escape(`${tablePrefix}${name}`), + indexName: (name) => queryRunner.connection.driver.escape(`IDX_${tablePrefix}${name}`), + }, + }; +} + +/** + * Initialize database and run all migrations up to (but not including) the specified migration. + * Useful for testing data transformations by inserting test data before a migration runs. + * + * @param beforeMigrationName - The class name of the migration to stop before (e.g., 'AddUserRole1234567890') + * @throws {UnexpectedError} If the migration is not found or database is not initialized + */ +export async function initDbUpToMigration(beforeMigrationName: string): Promise { + const dataSource = Container.get(DataSource); + + if (!Array.isArray(dataSource.options.migrations)) { + throw new UnexpectedError('Database migrations are not an array'); + } + + const allMigrations = dataSource.options.migrations as Migration[]; + const targetIndex = allMigrations.findIndex((m) => m.name === beforeMigrationName); + + if (targetIndex === -1) { + throw new UnexpectedError(`Migration "${beforeMigrationName}" not found`); + } + + // Temporarily replace migrations array with subset + const migrationsToRun = allMigrations.slice(0, targetIndex); + (dataSource.options as { migrations: Migration[] }).migrations = migrationsToRun; + + try { + // Need to reinitialize the data source to rebuild the migrations + await reinitializeDataConnection(); + // Run migrations + await Container.get(DbConnection).migrate(); + } finally { + // Restore full migrations array + (dataSource.options as { migrations: Migration[] }).migrations = allMigrations; + // Need to reinitialize the data source to rebuild the migrations + await reinitializeDataConnection(); + } +} + +/** + * Undo the last single migration down. + * Useful for testing the down path of a specific migration after inserting test data. + */ +export async function undoLastSingleMigration(): Promise { + const dataSource = Container.get(DataSource); + await dataSource.undoLastMigration({ + transaction: 'each', + }); +} + +/** + * Run a single migration by name. + * Useful for testing a specific migration after inserting test data. + * + * @param migrationName - The class name of the migration to run (e.g., 'AddUserRole1234567890') + * @throws {UnexpectedError} If the migration is not found or database is not initialized + */ +export async function runSingleMigration(migrationName: string): Promise { + const dataSource = Container.get(DataSource); + + const allMigrations = dataSource.options.migrations as Migration[]; + const migration = allMigrations.find((m) => m.name === migrationName); + + if (!migration) { + throw new UnexpectedError(`Migration "${migrationName}" not found`); + } + + // Temporarily replace migrations array with only the target migration + (dataSource.options as { migrations: Migration[] }).migrations = [migration]; + + try { + // Need to reinitialize the data source to rebuild the migrations + await reinitializeDataConnection(); + // Run migrations + await Container.get(DbConnection).migrate(); + } finally { + // Restore full migrations array + (dataSource.options as { migrations: Migration[] }).migrations = allMigrations; + // Need to reinitialize the data source to rebuild the migrations + await reinitializeDataConnection(); + } +} diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 492967fff26..2cd55af2361 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -80,7 +80,9 @@ type EntityName = | 'InsightsByPeriod' | 'InsightsMetadata' | 'DataTable' - | 'DataTableColumn'; + | 'DataTableColumn' + | 'ChatHubSession' + | 'ChatHubMessage'; /** * Truncate specific DB tables in a test DB. diff --git a/packages/@n8n/benchmark/Dockerfile b/packages/@n8n/benchmark/Dockerfile index 21fef933076..1b66714f87d 100644 --- a/packages/@n8n/benchmark/Dockerfile +++ b/packages/@n8n/benchmark/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:22.16.0 AS base +FROM node:22.18.0 AS base # Install required dependencies RUN apt-get update && apt-get install -y gnupg2 curl diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index 2949bbf84e8..a7597b5df7f 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-benchmark", - "version": "1.25.0", + "version": "1.26.0", "description": "Cli for running benchmark tests for n8n", "main": "dist/index", "scripts": { diff --git a/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.script.js b/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.script.js index 4ecee9d1bd2..3c01ec49272 100644 --- a/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.script.js +++ b/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.script.js @@ -4,7 +4,7 @@ import { check } from 'k6'; const apiBaseUrl = __ENV.API_BASE_URL; export default function () { - const res = http.post(`${apiBaseUrl}/webhook/benchmark-http-node`); + const res = http.post(`${apiBaseUrl}/webhook/benchmark-credential-http-node`); if (res.status !== 200) { console.error( diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index b2dd3cfdcae..6e066599a53 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.57.0", + "version": "1.58.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/src/configs/data-table.config.ts b/packages/@n8n/config/src/configs/data-table.config.ts index f9178eaaf1a..256fad3b318 100644 --- a/packages/@n8n/config/src/configs/data-table.config.ts +++ b/packages/@n8n/config/src/configs/data-table.config.ts @@ -9,9 +9,10 @@ export class DataTableConfig { /** * The percentage threshold at which a warning is triggered for data tables. * When the usage of a data table reaches or exceeds this value, a warning is issued. + * Defaults to 80% of maxSize if not explicitly set via environment variable. */ @Env('N8N_DATA_TABLES_WARNING_THRESHOLD_BYTES') - warningThreshold: number = 45 * 1024 * 1024; + warningThreshold?: number; /** * The duration in milliseconds for which the data table size is cached. diff --git a/packages/@n8n/config/src/configs/sso.config.ts b/packages/@n8n/config/src/configs/sso.config.ts index 919d04cb30c..c5ef6f6ea96 100644 --- a/packages/@n8n/config/src/configs/sso.config.ts +++ b/packages/@n8n/config/src/configs/sso.config.ts @@ -1,5 +1,7 @@ import { Config, Env, Nested } from '../decorators'; +type ScopesProvisioningFrequency = 'never' | 'first_login' | 'every_login'; + @Config class SamlConfig { /** Whether to enable SAML SSO. */ @@ -27,6 +29,33 @@ class LdapConfig { loginLabel: string = ''; } +@Config +class ProvisioningConfig { + /** Whether to provision the instance role from an SSO auth claim */ + @Env('N8N_SSO_SCOPES_PROVISION_INSTANCE_ROLE') + scopesProvisionInstanceRole: boolean = false; + + /** Whether to provision the project <> role mappings from an SSO auth claim */ + @Env('N8N_SSO_SCOPES_PROVISION_PROJECT_ROLES') + scopesProvisionProjectRoles: boolean = false; + + /** How often to trigger provisioning, never, fist login, or every login */ + @Env('N8N_SSO_SCOPES_PROVISIONING_FREQUENCY') + scopesProvisioningFrequency: ScopesProvisioningFrequency = 'never'; + + /** The name of scope to request on oauth flows */ + @Env('N8N_SSO_SCOPES_NAME') + scopesName: string = 'n8n'; + + /** The name of the expected claim to be returned for provisioning instance role */ + @Env('N8N_SSO_SCOPES_INSTANCE_ROLE_CLAIM_NAME') + scopesInstanceRoleClaimName: string = 'n8n_instance_role'; + + /** The name of the expected claim to be returned for provisioning project <> role mappings */ + @Env('N8N_SSO_SCOPES_PROJECTS_ROLES_CLAIM_NAME') + scopesProjectsRolesClaimName: string = 'n8n_projects'; +} + @Config export class SsoConfig { /** Whether to create users when they log in via SSO. */ @@ -45,4 +74,7 @@ export class SsoConfig { @Nested ldap: LdapConfig; + + @Nested + provisioning: ProvisioningConfig; } diff --git a/packages/@n8n/config/src/configs/user-management.config.ts b/packages/@n8n/config/src/configs/user-management.config.ts index fba482e8342..c6e61c28a0b 100644 --- a/packages/@n8n/config/src/configs/user-management.config.ts +++ b/packages/@n8n/config/src/configs/user-management.config.ts @@ -102,6 +102,16 @@ export class UserManagementConfig { @Env('N8N_USER_MANAGEMENT_JWT_DURATION_HOURS') jwtSessionDurationHours: number = 168; + /** + * Security Control: Invite Link Exposure Prevention + * + * When enabled, prevents exposure of invite URLs in API responses to users + * with 'user:create' permission, mitigating account takeover risks via + * invite link leakage (e.g., compromised admin accounts, network interception). + */ + @Env('N8N_INVITE_LINKS_EMAIL_ONLY') + inviteLinksEmailOnly: boolean = false; + /** * How long (in hours) before expiration to automatically refresh it. * - `0` means 25% of `N8N_USER_MANAGEMENT_JWT_DURATION_HOURS`. diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index a84ae9c697c..f820a72b32e 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -54,7 +54,6 @@ describe('GlobalConfig', () => { editorBaseUrl: '', dataTable: { maxSize: 50 * 1024 * 1024, - warningThreshold: 45 * 1024 * 1024, sizeCheckCacheDuration: 60000, }, database: { @@ -110,6 +109,7 @@ describe('GlobalConfig', () => { }, }, userManagement: { + inviteLinksEmailOnly: false, jwtSecret: '', jwtSessionDurationHours: 168, jwtRefreshTimeoutHours: 0, @@ -368,6 +368,14 @@ describe('GlobalConfig', () => { loginEnabled: false, loginLabel: '', }, + provisioning: { + scopesProvisionInstanceRole: false, + scopesProvisionProjectRoles: false, + scopesProvisioningFrequency: 'never', + scopesName: 'n8n', + scopesInstanceRoleClaimName: 'n8n_instance_role', + scopesProjectsRolesClaimName: 'n8n_projects', + }, }, redis: { prefix: 'n8n', diff --git a/packages/@n8n/create-node/README.md b/packages/@n8n/create-node/README.md index 14f3d58496e..d01c73cb5b5 100644 --- a/packages/@n8n/create-node/README.md +++ b/packages/@n8n/create-node/README.md @@ -160,6 +160,14 @@ Validates: - Common integration issues - Cloud publication readiness +### Cloud support + +```bash +npx n8n-node cloud-support +``` + +Manage n8n Cloud publication eligibility. In strict mode, your node must use the default ESLint config and pass all community node rules to be eligible for n8n Cloud publication. + Fix issues automatically: ```bash diff --git a/packages/@n8n/create-node/package.json b/packages/@n8n/create-node/package.json index 0ba11277a05..f890cd86ca0 100644 --- a/packages/@n8n/create-node/package.json +++ b/packages/@n8n/create-node/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/create-node", - "version": "0.11.0", + "version": "0.13.0", "description": "Official CLI to create new community nodes for n8n", "bin": { "create-node": "bin/create-node.cjs" diff --git a/packages/@n8n/db/package.json b/packages/@n8n/db/package.json index 9a1c6870662..3e7a0fc7ee4 100644 --- a/packages/@n8n/db/package.json +++ b/packages/@n8n/db/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/db", - "version": "0.26.0", + "version": "0.27.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/db/src/entities/execution-data.ts b/packages/@n8n/db/src/entities/execution-data.ts index 275047525b7..3134b2b107f 100644 --- a/packages/@n8n/db/src/entities/execution-data.ts +++ b/packages/@n8n/db/src/entities/execution-data.ts @@ -3,6 +3,7 @@ import { IWorkflowBase } from 'n8n-workflow'; import { JsonColumn } from './abstract-entity'; import { ExecutionEntity } from './execution-entity'; +import { ISimplifiedPinData } from './types-db'; import { idStringifier } from '../utils/transformers'; @Entity() @@ -15,8 +16,13 @@ export class ExecutionData { // This is because manual executions of unsaved workflows have no workflow id // and IWorkflowDb has it as a mandatory field. IWorkflowBase reflects the correct // data structure for this entity. + /** + * Workaround: Pindata causes TS errors from excessively deep type instantiation + * due to `INodeExecutionData`, so we use a simplified version so `QueryDeepPartialEntity` + * can resolve and calls to `update`, `insert`, and `insert` pass typechecking. + */ @JsonColumn() - workflowData: IWorkflowBase; + workflowData: Omit & { pinData?: ISimplifiedPinData }; @PrimaryColumn({ transformer: idStringifier }) executionId: string; diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index bbe718c4917..0b65b2f8952 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -12,6 +12,9 @@ import type { AnnotationVote, ExecutionSummary, IUser, + IDataObject, + IBinaryKeyData, + IPairedItemData, } from 'n8n-workflow'; import { z } from 'zod'; @@ -385,3 +388,15 @@ export type AuthenticatedRequest< 'push-ref': string; }; }; + +/** + * Simplified to prevent excessively deep type instantiation error from + * `INodeExecutionData` in `IPinData` in a TypeORM entity field. + */ +export interface ISimplifiedPinData { + [nodeName: string]: Array<{ + json: IDataObject; + binary?: IBinaryKeyData; + pairedItem?: IPairedItemData | IPairedItemData[] | number; + }>; +} diff --git a/packages/@n8n/db/src/entities/workflow-entity.ts b/packages/@n8n/db/src/entities/workflow-entity.ts index 4e6cbcd6c12..fc78500ed4d 100644 --- a/packages/@n8n/db/src/entities/workflow-entity.ts +++ b/packages/@n8n/db/src/entities/workflow-entity.ts @@ -10,14 +10,14 @@ import { } from '@n8n/typeorm'; import { Length } from 'class-validator'; import { IConnections, IDataObject, IWorkflowSettings, WorkflowFEMeta } from 'n8n-workflow'; -import type { IBinaryKeyData, INode, IPairedItemData } from 'n8n-workflow'; +import type { INode } from 'n8n-workflow'; import { JsonColumn, WithTimestampsAndStringId, dbType } from './abstract-entity'; import { type Folder } from './folder'; import type { SharedWorkflow } from './shared-workflow'; import type { TagEntity } from './tag-entity'; import type { TestRun } from './test-run.ee'; -import type { IWorkflowDb } from './types-db'; +import type { ISimplifiedPinData, IWorkflowDb } from './types-db'; import type { WorkflowStatistics } from './workflow-statistics'; import type { WorkflowTagMapping } from './workflow-tag-mapping'; import { objectRetriever, sqlite } from '../utils/transformers'; @@ -113,15 +113,3 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl @OneToMany('TestRun', 'workflow') testRuns: TestRun[]; } - -/** - * Simplified to prevent excessively deep type instantiation error from - * `INodeExecutionData` in `IPinData` in a TypeORM entity field. - */ -export interface ISimplifiedPinData { - [nodeName: string]: Array<{ - json: IDataObject; - binary?: IBinaryKeyData; - pairedItem?: IPairedItemData | IPairedItemData[] | number; - }>; -} diff --git a/packages/@n8n/db/src/migrations/common/1759399811000-ChangeValueTypesForInsights.ts b/packages/@n8n/db/src/migrations/common/1759399811000-ChangeValueTypesForInsights.ts new file mode 100644 index 00000000000..3bf3b474fc8 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1759399811000-ChangeValueTypesForInsights.ts @@ -0,0 +1,95 @@ +import type { IrreversibleMigration, MigrationContext } from '../migration-types'; + +const INSIGHTS_RAW_TABLE_NAME = 'insights_raw'; +const INSIGHTS_RAW_TEMP_TABLE_NAME = 'temp_insights_raw'; +const INSIGHTS_BY_PERIOD_TABLE_NAME = 'insights_by_period'; +const INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME = 'temp_insights_by_period'; +const INSIGHTS_METADATA_TABLE_NAME = 'insights_metadata'; +const VALUE_COLUMN_NAME = 'value'; + +export class ChangeValueTypesForInsights1759399811000 implements IrreversibleMigration { + async up({ + isSqlite, + isMysql, + isPostgres, + escape, + copyTable, + queryRunner, + schemaBuilder: { createTable, column, dropTable }, + }: MigrationContext) { + const insightsRawTable = escape.tableName(INSIGHTS_RAW_TABLE_NAME); + const insightsByPeriodTable = escape.tableName(INSIGHTS_BY_PERIOD_TABLE_NAME); + const valueColumnName = escape.columnName(VALUE_COLUMN_NAME); + + if (isSqlite) { + const tempInsightsByPeriodTable = escape.tableName(INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME); + const tempInsightsRawTable = escape.tableName(INSIGHTS_RAW_TEMP_TABLE_NAME); + const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure'; + + // Create temporary raw table with new value type, copy data, remove the original table and rename the temporary table + await createTable(INSIGHTS_RAW_TEMP_TABLE_NAME) + .withColumns( + column('id').int.primary.autoGenerate2, + column('metaId').int.notNull, + column('type').int.notNull.comment(typeComment), + column('value').bigint.notNull, + column('timestamp').timestampTimezone(0).default('CURRENT_TIMESTAMP').notNull, + ) + .withForeignKey('metaId', { + tableName: INSIGHTS_METADATA_TABLE_NAME, + columnName: 'metaId', + onDelete: 'CASCADE', + }); + + // Copy data from the original table to the temporary table + await copyTable(INSIGHTS_RAW_TABLE_NAME, INSIGHTS_RAW_TEMP_TABLE_NAME); + + // drop the original table + await dropTable(INSIGHTS_RAW_TABLE_NAME); + + // rename the temporary table to the original table name + await queryRunner.query(`ALTER TABLE ${tempInsightsRawTable} RENAME TO ${insightsRawTable};`); + + await createTable(INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME) + .withColumns( + column('id').int.primary.autoGenerate2, + column('metaId').int.notNull, + column('type').int.notNull.comment(typeComment), + column('value').bigint.notNull, + column('periodUnit').int.notNull.comment('0: hour, 1: day, 2: week'), + column('periodStart').default('CURRENT_TIMESTAMP').timestampTimezone(0), + ) + .withForeignKey('metaId', { + tableName: INSIGHTS_METADATA_TABLE_NAME, + columnName: 'metaId', + onDelete: 'CASCADE', + }) + .withIndexOn(['periodStart', 'type', 'periodUnit', 'metaId'], true); + + // Copy data from the original table to the temporary table + await copyTable(INSIGHTS_BY_PERIOD_TABLE_NAME, INSIGHTS_BY_PERIOD_TEMP_TABLE_NAME); + + // drop the original table + await dropTable(INSIGHTS_BY_PERIOD_TABLE_NAME); + + // rename the temporary table to the original table name + await queryRunner.query( + `ALTER TABLE ${tempInsightsByPeriodTable} RENAME TO ${insightsByPeriodTable};`, + ); + } else if (isMysql) { + await queryRunner.query( + `ALTER TABLE ${insightsRawTable} MODIFY COLUMN ${valueColumnName} BIGINT NOT NULL;`, + ); + await queryRunner.query( + `ALTER TABLE ${insightsByPeriodTable} MODIFY COLUMN ${valueColumnName} BIGINT NOT NULL;`, + ); + } else if (isPostgres) { + await queryRunner.query( + `ALTER TABLE ${insightsRawTable} ALTER COLUMN ${valueColumnName} TYPE BIGINT;`, + ); + await queryRunner.query( + `ALTER TABLE ${insightsByPeriodTable} ALTER COLUMN ${valueColumnName} TYPE BIGINT;`, + ); + } + } +} diff --git a/packages/@n8n/db/src/migrations/common/1760019379982-CreateChatHubTables.ts b/packages/@n8n/db/src/migrations/common/1760019379982-CreateChatHubTables.ts new file mode 100644 index 00000000000..5d00b187cba --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1760019379982-CreateChatHubTables.ts @@ -0,0 +1,116 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + sessions: 'chat_hub_sessions', + messages: 'chat_hub_messages', + user: 'user', + credentials: 'credentials_entity', + workflows: 'workflow_entity', + executions: 'execution_entity', +} as const; + +export class CreateChatHubTables1760019379982 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(table.sessions) + .withColumns( + column('id').uuid.primary, + column('title').varchar(256).notNull, + column('ownerId').uuid.notNull, + column('lastMessageAt').timestampTimezone(), + + column('credentialId').varchar(36), + column('provider') + .varchar(16) + .comment('ChatHubProvider enum: "openai", "anthropic", "google", "n8n"'), + column('model') + .varchar(64) + .comment('Model name used at the respective Model node, ie. "gpt-4"'), + column('workflowId').varchar(36), + ) + .withForeignKey('ownerId', { + tableName: table.user, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('credentialId', { + tableName: table.credentials, + columnName: 'id', + onDelete: 'SET NULL', + }) + .withForeignKey('workflowId', { + tableName: table.workflows, + columnName: 'id', + onDelete: 'SET NULL', + }).withTimestamps; + + await createTable(table.messages) + .withColumns( + column('id').uuid.primary.notNull, + column('sessionId').uuid.notNull, + column('previousMessageId').uuid, + column('revisionOfMessageId').uuid, + column('turnId').uuid, + column('retryOfMessageId').uuid, + column('type') + .varchar(16) + .notNull.comment('ChatHubMessageType enum: "human", "ai", "system", "tool", "generic"'), + column('name').varchar(128).notNull, + column('state') + .varchar(16) + .default("'active'") + .notNull.comment('ChatHubMessageState enum: "active", "superseded", "hidden", "deleted"'), + column('content').text.notNull, + column('provider') + .varchar(16) + .comment('ChatHubProvider enum: "openai", "anthropic", "google", "n8n"'), + column('model') + .varchar(64) + .comment('Model name used at the respective Model node, ie. "gpt-4"'), + column('workflowId').varchar(36), + column('runIndex') + .int.notNull.default(0) + .comment('The nth attempt this message has been generated/retried this turn'), + column('executionId').int, + ) + .withForeignKey('sessionId', { + tableName: table.sessions, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('previousMessageId', { + tableName: table.messages, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('workflowId', { + tableName: table.workflows, + columnName: 'id', + onDelete: 'SET NULL', + }) + .withForeignKey('turnId', { + tableName: table.messages, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('retryOfMessageId', { + tableName: table.messages, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('revisionOfMessageId', { + tableName: table.messages, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('executionId', { + tableName: table.executions, + columnName: 'id', + onDelete: 'SET NULL', + }).withTimestamps; + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(table.messages); + await dropTable(table.sessions); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1760020838000-UniqueRoleNames.ts b/packages/@n8n/db/src/migrations/common/1760020838000-UniqueRoleNames.ts new file mode 100644 index 00000000000..d067f939353 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1760020838000-UniqueRoleNames.ts @@ -0,0 +1,64 @@ +import type { Role } from '../../entities'; +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class UniqueRoleNames1760020838000 implements ReversibleMigration { + async up({ isMysql, escape, runQuery }: MigrationContext) { + const tableName = escape.tableName('role'); + const displayNameColumn = escape.columnName('displayName'); + const slugColumn = escape.columnName('slug'); + const createdAtColumn = escape.columnName('createdAt'); + const allRoles: Array> = await runQuery( + `SELECT ${slugColumn}, ${displayNameColumn} FROM ${tableName} ORDER BY ${displayNameColumn}, ${createdAtColumn} ASC`, + ); + + // Group roles by displayName in memory + const groupedByName = new Map>>(); + + for (const role of allRoles) { + const existing = groupedByName.get(role.displayName) || []; + existing.push(role); + groupedByName.set(role.displayName, existing); + } + + for (const [_, roles] of groupedByName.entries()) { + if (roles.length > 1) { + const duplicates = roles.slice(1); + let index = 2; + for (const role of duplicates.values()) { + let newDisplayName = `${role.displayName} ${index}`; + while (allRoles.some((r) => r.displayName === newDisplayName)) { + index++; + newDisplayName = `${role.displayName} ${index}`; + } + await runQuery( + `UPDATE ${tableName} SET ${displayNameColumn} = :displayName WHERE ${slugColumn} = :slug`, + { + displayName: newDisplayName, + slug: role.slug, + }, + ); + index++; + } + } + } + + const indexName = escape.indexName('UniqueRoleDisplayName'); + // MySQL cannot create an index on a column with a type of TEXT or BLOB without a length limit + // The (100) specifies the maximum length of the index key + // meaning that only the first 100 characters of the displayName column will be used for indexing + // But since in our DTOs we limit the displayName to 100 characters, we can safely use this prefix length + await runQuery( + isMysql + ? `CREATE UNIQUE INDEX ${indexName} ON ${tableName} (${displayNameColumn}(100))` + : `CREATE UNIQUE INDEX ${indexName} ON ${tableName} (${displayNameColumn})`, + ); + } + + async down({ isMysql, escape, runQuery }: MigrationContext) { + const tableName = escape.tableName('role'); + const indexName = escape.indexName('UniqueRoleDisplayName'); + await runQuery( + isMysql ? `ALTER TABLE ${tableName} DROP INDEX ${indexName}` : `DROP INDEX ${indexName}`, + ); + } +} diff --git a/packages/@n8n/db/src/migrations/dsl/column.ts b/packages/@n8n/db/src/migrations/dsl/column.ts index 847dbb924a8..10ccc3e17e2 100644 --- a/packages/@n8n/db/src/migrations/dsl/column.ts +++ b/packages/@n8n/db/src/migrations/dsl/column.ts @@ -10,7 +10,8 @@ export class Column { | 'timestamptz' | 'timestamp' | 'uuid' - | 'double'; + | 'double' + | 'bigint'; private isGenerated = false; @@ -40,6 +41,11 @@ export class Column { return this; } + get bigint() { + this.type = 'bigint'; + return this; + } + get double() { this.type = 'double'; return this; @@ -176,6 +182,8 @@ export class Column { } else if (isSqlite) { options.type = 'real'; } + } else if (type === 'bigint') { + options.type = 'bigint'; } if ( diff --git a/packages/@n8n/db/src/migrations/migration-helpers.ts b/packages/@n8n/db/src/migrations/migration-helpers.ts index 1a8130a0d4d..ce4aeb497d4 100644 --- a/packages/@n8n/db/src/migrations/migration-helpers.ts +++ b/packages/@n8n/db/src/migrations/migration-helpers.ts @@ -178,6 +178,11 @@ const createContext = (queryRunner: QueryRunner, migration: Migration): Migratio }); export const wrapMigration = (migration: Migration) => { + const prototype = migration.prototype as unknown as { __n8n_wrapped?: boolean }; + if (prototype.__n8n_wrapped === true) { + return; + } + prototype.__n8n_wrapped = true; const { up, down } = migration.prototype; if (up) { Object.assign(migration.prototype, { diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index 86a8263d120..feb130f87ec 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -99,6 +99,9 @@ import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752 import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes'; +import { ChangeValueTypesForInsights1759399811000 } from '../common/1759399811000-ChangeValueTypesForInsights'; +import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables'; +import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; import type { Migration } from '../migration-types'; import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; @@ -205,4 +208,7 @@ export const mysqlMigrations: Migration[] = [ AddTimestampsToRoleAndRoleIndexes1756906557570, AddProjectIdToVariableTable1758794506893, AddAudienceColumnToApiKeys1758731786132, + ChangeValueTypesForInsights1759399811000, + CreateChatHubTables1760019379982, + UniqueRoleNames1760020838000, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index d557a9c5df7..f399b61c34a 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -99,6 +99,9 @@ import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-Crea import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes'; import { AddAudienceColumnToApiKeys1758731786132 } from '../common/1758731786132-AddAudienceColumnToApiKey'; +import { ChangeValueTypesForInsights1759399811000 } from '../common/1759399811000-ChangeValueTypesForInsights'; +import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables'; +import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -203,4 +206,7 @@ export const postgresMigrations: Migration[] = [ AddTimestampsToRoleAndRoleIndexes1756906557570, AddProjectIdToVariableTable1758794506893, AddAudienceColumnToApiKeys1758731786132, + ChangeValueTypesForInsights1759399811000, + CreateChatHubTables1760019379982, + UniqueRoleNames1760020838000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index e02222be28e..55c12939e6a 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -94,9 +94,12 @@ import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752 import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes'; +import { ChangeValueTypesForInsights1759399811000 } from '../common/1759399811000-ChangeValueTypesForInsights'; +import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; import type { Migration } from '../migration-types'; import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable'; +import { CreateChatHubTables1760019379982 } from '../common/1760019379982-CreateChatHubTables'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -197,6 +200,9 @@ const sqliteMigrations: Migration[] = [ AddTimestampsToRoleAndRoleIndexes1756906557570, AddProjectIdToVariableTable1758794506893, AddAudienceColumnToApiKeys1758731786132, + ChangeValueTypesForInsights1759399811000, + CreateChatHubTables1760019379982, + UniqueRoleNames1760020838000, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 1940c2c8adc..193e94b039a 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -448,7 +448,6 @@ export class ExecutionRepository extends Repository { } if (Object.keys(executionData).length > 0) { - // @ts-expect-error Fix typing await this.executionDataRepository.update({ executionId }, executionData); } @@ -463,7 +462,6 @@ export class ExecutionRepository extends Repository { } if (Object.keys(executionData).length > 0) { - // @ts-expect-error Fix typing await tx.update(ExecutionData, { executionId }, executionData); } }); diff --git a/packages/@n8n/decorators/package.json b/packages/@n8n/decorators/package.json index f3716356a1f..14f53d3d9fc 100644 --- a/packages/@n8n/decorators/package.json +++ b/packages/@n8n/decorators/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/decorators", - "version": "0.25.0", + "version": "0.26.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/eslint-config/package.json b/packages/@n8n/eslint-config/package.json index 38fbd88ed6c..c8439b6ed98 100644 --- a/packages/@n8n/eslint-config/package.json +++ b/packages/@n8n/eslint-config/package.json @@ -46,7 +46,6 @@ "eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-vue": "^10.2.0", "globals": "^16.2.0", - "tsup": "catalog:", "typescript": "catalog:", "typescript-eslint": "^8.35.0", "vitest": "catalog:" diff --git a/packages/@n8n/eslint-config/src/configs/frontend.ts b/packages/@n8n/eslint-config/src/configs/frontend.ts index ac2bdf60b15..715589dacb3 100644 --- a/packages/@n8n/eslint-config/src/configs/frontend.ts +++ b/packages/@n8n/eslint-config/src/configs/frontend.ts @@ -64,7 +64,6 @@ export const frontendConfig = tseslint.config( 'error', { ignorePatterns: [ - 'FontAwesomeIcon', // Globally registered in plugins/icons/index.ts 'RouterLink', // Vue Router global component 'RouterView', // Vue Router global component 'Teleport', // Vue 3 built-in diff --git a/packages/@n8n/eslint-plugin-community-nodes/README.md b/packages/@n8n/eslint-plugin-community-nodes/README.md new file mode 100644 index 00000000000..df1d16fd89f --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/README.md @@ -0,0 +1,60 @@ +# @n8n/eslint-plugin-community-nodes + +ESLint plugin for linting n8n community node packages to ensure consistency and best practices. + +## Install + +```sh +npm install --save-dev eslint @n8n/eslint-plugin-community-nodes +``` + +**Requires ESLint `>=9` and [flat config](https://eslint.org/docs/latest/use/configure/configuration-files) + +## Usage + +See the [ESLint docs](https://eslint.org/docs/latest/use/configure/configuration-files) for more information about extending config files. + +### Recommended config + +This plugin exports a `recommended` config that enforces good practices. + +```js +import { n8nCommunityNodesPlugin } from '@n8n/eslint-plugin-community-nodes'; + +export default [ + // … + n8nCommunityNodesPlugin.configs.recommended, + { + rules: { + '@n8n/community-nodes/node-usable-as-tool': 'warn', + }, + }, +]; +``` + +## Rules + + + +💼 Configurations enabled in.\ +⚠️ Configurations set to warn in.\ +✅ Set in the `recommended` configuration.\ +☑️ Set in the `recommendedWithoutN8nCloudSupport` configuration.\ +🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ +💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + +| Name                             | Description | 💼 | ⚠️ | 🔧 | 💡 | +| :--------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | :--- | :--- | :- | :- | +| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or camelCase slug) | ✅ ☑️ | | | | +| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | +| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 | +| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, are SVG format, and light/dark icons are different | ✅ ☑️ | | | 💡 | +| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 | +| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 | +| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | | +| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | | +| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | | +| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 | +| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | | + + diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-documentation-url.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-documentation-url.md new file mode 100644 index 00000000000..c3d08033996 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-documentation-url.md @@ -0,0 +1,94 @@ +# Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) (`@n8n/community-nodes/credential-documentation-url`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + + + +## Options + + + +| Name | Description | Type | +| :----------- | :----------------------------------------------------- | :------ | +| `allowSlugs` | Whether to allow lowercase alphanumeric slugs with slashes | Boolean | +| `allowUrls` | Whether to allow valid URLs | Boolean | + + + +## Rule Details + +Ensures that credential `documentationUrl` values are in a valid format. For community packages, this should always be a complete URL to your documentation. + +The lowercase alphanumeric slug option (`allowSlugs`) is only intended for internal n8n use when referring to slugs on docs.n8n.io, and should not be used in community packages. When enabled, uppercase letters in slugs will be automatically converted to lowercase. + +## Examples + +### ❌ Incorrect + +```typescript +export class MyApiCredential implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + documentationUrl = 'invalid-url-format'; // Not a valid URL + // ... +} +``` + +```typescript +export class MyApiCredential implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + documentationUrl = 'MyApi'; // Invalid: uppercase letters (will be autofixed to 'myapi') + // ... +} +``` + +```typescript +export class MyApiCredential implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + documentationUrl = 'my-api'; // Invalid: special characters not allowed + // ... +} +``` + +### ✅ Correct + +```typescript +export class MyApiCredential implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + documentationUrl = 'https://docs.myservice.com/api-setup'; // Complete URL to documentation + // ... +} +``` + +```typescript +export class MyApiCredential implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + documentationUrl = 'https://github.com/myuser/n8n-nodes-myapi#credentials'; // GitHub README section + // ... +} +``` + +## Configuration + +By default, only URLs are allowed, which is the recommended setting for community packages. + +The `allowSlugs` option is available for internal n8n development: + +```json +{ + "rules": { + "@n8n/community-nodes/credential-documentation-url": [ + "error", + { + "allowSlugs": true + } + ] + } +} +``` + +**Note:** Community package developers should keep the default settings and always use complete URLs for their documentation. diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-password-field.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-password-field.md new file mode 100644 index 00000000000..bb0cdca5139 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-password-field.md @@ -0,0 +1,45 @@ +# Ensure credential fields with sensitive names have typeOptions.password = true (`@n8n/community-nodes/credential-password-field`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +## Rule Details + +Ensures that credential fields with names like "password", "secret", "token", or "key" are properly masked in the UI by having `typeOptions.password = true`. + +## Examples + +### ❌ Incorrect + +```typescript +export class MyApiCredential implements ICredentialType { + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + // Missing typeOptions.password + }, + ]; +} +``` + +### ✅ Correct + +```typescript +export class MyApiCredential implements ICredentialType { + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + default: '', + }, + ]; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-test-required.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-test-required.md new file mode 100644 index 00000000000..3e1f36cd04d --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/credential-test-required.md @@ -0,0 +1,58 @@ +# Ensure credentials have a credential test (`@n8n/community-nodes/credential-test-required`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + +## Rule Details + +Ensures that your credentials include a `test` method to validate user credentials. This helps users verify their credentials are working correctly. + +## Examples + +### ❌ Incorrect + +```typescript +export class MyApiCredential implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + default: '', + }, + ]; + // Missing test method +} +``` + +### ✅ Correct + +```typescript +export class MyApiCredential implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + default: '', + }, + ]; + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://api.myservice.com', + url: '/user', + method: 'GET', + }, + }; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/icon-validation.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/icon-validation.md new file mode 100644 index 00000000000..66be5ea225f --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/icon-validation.md @@ -0,0 +1,67 @@ +# Validate node and credential icon files exist, are SVG format, and light/dark icons are different (`@n8n/community-nodes/icon-validation`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + +## Rule Details + +Validates that your node and credential icon files exist, are in SVG format, and use the correct `file:` protocol. Icons must be different files when providing light/dark theme variants. + +## Examples + +### ❌ Incorrect + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + icon: 'icons/my-icon.png', // Missing 'file:' prefix, wrong format + // ... + }; +} +``` + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + icon: { + light: 'file:icons/my-icon.svg', + dark: 'file:icons/my-icon.svg', // Same file for both themes + }, + // ... + }; +} +``` + +### ✅ Correct + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + icon: 'file:icons/my-service.svg', // Correct format + // ... + }; +} +``` + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + icon: { + light: 'file:icons/my-service-light.svg', + dark: 'file:icons/my-service-dark.svg', // Different files + }, + // ... + }; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-credential-reuse.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-credential-reuse.md new file mode 100644 index 00000000000..9e0f980336d --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-credential-reuse.md @@ -0,0 +1,82 @@ +# Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package (`@n8n/community-nodes/no-credential-reuse`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + +## Rule Details + +Ensures your nodes only reference credentials by their `name` property that match credential classes declared in your package's `package.json` file. This prevents security issues where nodes could access credentials from other packages. + +## Examples + +### ❌ Incorrect + +```typescript +// MyApiCredential.credentials.ts +export class MyApiCredential implements ICredentialType { + name = 'myApiCredential'; + displayName = 'My API'; + // ... +} + +// package.json: "n8n": { "credentials": ["dist/credentials/MyApiCredential.credentials.js"] } + +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + credentials: [ + { + name: 'someOtherCredential', // No credential class with this name in package + required: true, + }, + ], + // ... + }; +} +``` + +### ✅ Correct + +```typescript +// MyApiCredential.credentials.ts +export class MyApiCredential implements ICredentialType { + name = 'myApiCredential'; // This name must match what's used in nodes + displayName = 'My API'; + // ... +} + +// package.json: "n8n": { "credentials": ["dist/credentials/MyApiCredential.credentials.js"] } + +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + credentials: [ + { + name: 'myApiCredential', // Matches credential class name property + required: true, + }, + ], + // ... + }; +} +``` + +## Setup + +Declare your credential files in `package.json` and ensure the credential name in nodes matches the `name` property in your credential classes: + +```json +{ + "name": "n8n-nodes-my-service", + "n8n": { + "credentials": [ + "dist/credentials/MyApiCredential.credentials.js" + ] + } +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-deprecated-workflow-functions.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-deprecated-workflow-functions.md new file mode 100644 index 00000000000..f40bb0dfb7f --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-deprecated-workflow-functions.md @@ -0,0 +1,61 @@ +# Disallow usage of deprecated functions and types from n8n-workflow package (`@n8n/community-nodes/no-deprecated-workflow-functions`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + +## Rule Details + +Prevents usage of deprecated functions from n8n-workflow package and suggests modern alternatives. + +## Examples + +### ❌ Incorrect + +```typescript +import { IRequestOptions } from 'n8n-workflow'; + +export class MyNode implements INodeType { + async execute(this: IExecuteFunctions) { + // Using deprecated request helper function + const response = await this.helpers.request({ + method: 'GET', + url: 'https://api.example.com/data', + }); + + // Using deprecated type + const options: IRequestOptions = { + method: 'POST', + url: 'https://api.example.com/data', + }; + + return [this.helpers.returnJsonArray([response])]; + } +} +``` + +### ✅ Correct + +```typescript +import { IHttpRequestOptions } from 'n8n-workflow'; + +export class MyNode implements INodeType { + async execute(this: IExecuteFunctions) { + // Using modern httpRequest helper function + const response = await this.helpers.httpRequest({ + method: 'GET', + url: 'https://api.example.com/data', + }); + + // Using modern type + const options: IHttpRequestOptions = { + method: 'POST', + url: 'https://api.example.com/data', + }; + + return [this.helpers.returnJsonArray([response])]; + } +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-restricted-globals.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-restricted-globals.md new file mode 100644 index 00000000000..57f3d674429 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-restricted-globals.md @@ -0,0 +1,44 @@ +# Disallow usage of restricted global variables in community nodes (`@n8n/community-nodes/no-restricted-globals`) + +💼 This rule is enabled in the ✅ `recommended` config. + + + +## Rule Details + +Prevents the use of Node.js global variables that are not allowed in n8n Cloud. While these globals may be available in self-hosted environments, they are restricted on n8n Cloud for security and stability reasons. + +Restricted globals include: `clearInterval`, `clearTimeout`, `global`, `globalThis`, `process`, `setInterval`, `setTimeout`, `setImmediate`, `clearImmediate`, `__dirname`, `__filename`. + +## Examples + +### ❌ Incorrect + +```typescript +export class MyNode implements INodeType { + async execute(this: IExecuteFunctions) { + // These globals are not allowed on n8n Cloud + const pid = process.pid; + const dir = __dirname; + + setTimeout(() => { + console.log('This will not work on n8n Cloud'); + }, 1000); + + return this.prepareOutputData([]); + } +} +``` + +### ✅ Correct + +```typescript +export class MyNode implements INodeType { + async execute(this: IExecuteFunctions) { + // Use n8n context methods instead + const timezone = this.getTimezone(); + + return this.prepareOutputData([]); + } +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-restricted-imports.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-restricted-imports.md new file mode 100644 index 00000000000..29105cda053 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-restricted-imports.md @@ -0,0 +1,47 @@ +# Disallow usage of restricted imports in community nodes (`@n8n/community-nodes/no-restricted-imports`) + +💼 This rule is enabled in the ✅ `recommended` config. + + + +## Rule Details + +Prevents importing external dependencies that are not allowed on n8n Cloud. Community nodes running on n8n Cloud are restricted to a specific set of allowed modules for security and performance reasons. + +**Allowed modules:** `n8n-workflow`, `lodash`, `moment`, `p-limit`, `luxon`, `zod`, `crypto`, `node:crypto` + +Relative imports (starting with `./` or `../`) are always allowed. + +## Examples + +### ❌ Incorrect + +```typescript +import axios from 'axios'; // External dependency not allowed +import { readFile } from 'fs'; // Node.js modules not in allowlist +const request = require('request'); // Same applies to require() + +// Dynamic imports are also restricted +const module = await import('some-package'); +``` + +### ✅ Correct + +```typescript +import { IExecuteFunctions, INodeType } from 'n8n-workflow'; // Allowed +import { get } from 'lodash'; // Allowed +import moment from 'moment'; // Allowed +import { DateTime } from 'luxon'; // Allowed +import { createHash } from 'crypto'; // Allowed + +import { MyHelper } from './helpers/MyHelper'; // Relative imports allowed +import config from '../config'; // Relative imports allowed + +export class MyNode implements INodeType { + // ... implementation +} +``` + +## When This Rule Doesn't Apply + +This rule only applies to community nodes intended for n8n Cloud. If you're building nodes exclusively for self-hosted environments, you may disable this rule, but be aware that your package will not be compatible with n8n Cloud. diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/node-usable-as-tool.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/node-usable-as-tool.md new file mode 100644 index 00000000000..f1ac566c93a --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/node-usable-as-tool.md @@ -0,0 +1,43 @@ +# Ensure node classes have usableAsTool property (`@n8n/community-nodes/node-usable-as-tool`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +## Rule Details + +Ensures your nodes declare whether they can be used as tools in AI workflows. This property helps n8n determine if your node is suitable for AI-assisted automation. + +## Examples + +### ❌ Incorrect + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + group: ['input'], + version: 1, + // Missing usableAsTool property + properties: [], + }; +} +``` + +### ✅ Correct + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Node', + name: 'myNode', + group: ['input'], + version: 1, + usableAsTool: true, + properties: [], + }; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/package-name-convention.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/package-name-convention.md new file mode 100644 index 00000000000..ecccd873630 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/package-name-convention.md @@ -0,0 +1,52 @@ +# Enforce correct package naming convention for n8n community nodes (`@n8n/community-nodes/package-name-convention`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + +## Rule Details + +Validates that your package name follows the correct n8n community node naming convention. Package names must start with `n8n-nodes-` and can optionally be scoped. + +## Examples + +### ❌ Incorrect + +```json +{ + "name": "my-service-integration" +} +``` + +```json +{ + "name": "nodes-my-service" +} +``` + +```json +{ + "name": "@company/my-service" +} +``` + +### ✅ Correct + +```json +{ + "name": "n8n-nodes-my-service" +} +``` + +```json +{ + "name": "@company/n8n-nodes-my-service" +} +``` + +## Best Practices + +- Use descriptive service names: `n8n-nodes-github` rather than `n8n-nodes-api` +- For company packages, use your organization scope: `@mycompany/n8n-nodes-internal-tool` diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/resource-operation-pattern.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/resource-operation-pattern.md new file mode 100644 index 00000000000..3d8df41f352 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/resource-operation-pattern.md @@ -0,0 +1,84 @@ +# Enforce proper resource/operation pattern for better UX in n8n nodes (`@n8n/community-nodes/resource-operation-pattern`) + +⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + + + +## Rule Details + +Warns when a node has more than 5 operations without organizing them into resources. The resource/operation pattern improves user experience by grouping related operations together, making complex nodes easier to navigate. + +When you have many operations, users benefit from having them organized into logical resource groups (e.g., "User", "Project", "File") rather than seeing a long flat list of operations. + +## Examples + +### ❌ Incorrect + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Service', + name: 'myService', + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { name: 'Get User', value: 'getUser' }, + { name: 'Create User', value: 'createUser' }, + { name: 'Update User', value: 'updateUser' }, + { name: 'Delete User', value: 'deleteUser' }, + { name: 'Get Project', value: 'getProject' }, + { name: 'Create Project', value: 'createProject' }, + { name: 'List Files', value: 'listFiles' }, + // 7+ operations without resources - hard to navigate! + ], + }, + // ... other properties + ], + }; +} +``` + +### ✅ Correct + +```typescript +export class MyNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Service', + name: 'myService', + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { name: 'User', value: 'user' }, + { name: 'Project', value: 'project' }, + { name: 'File', value: 'file' }, + ], + default: 'user', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { name: 'Get', value: 'get' }, + { name: 'Create', value: 'create' }, + { name: 'Update', value: 'update' }, + { name: 'Delete', value: 'delete' }, + ], + default: 'get', + }, + // ... similar operation blocks for 'project' and 'file' resources + ], + }; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/eslint.config.mjs b/packages/@n8n/eslint-plugin-community-nodes/eslint.config.mjs new file mode 100644 index 00000000000..4a827a63093 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/eslint.config.mjs @@ -0,0 +1,27 @@ +import { defineConfig } from 'eslint/config'; +import { nodeConfig } from '@n8n/eslint-config/node'; +import eslintPlugin from 'eslint-plugin-eslint-plugin'; + +export default defineConfig([ + nodeConfig, + eslintPlugin.configs.recommended, + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + project: './tsconfig.json', + allowDefaultProject: true, + }, + }, + rules: { + // We use RuleCreator which adds this automatically + 'eslint-plugin/require-meta-docs-url': 'off', + // typescript-eslint uses different pattern + 'eslint-plugin/require-meta-default-options': 'off', + // Disable naming convention for plugin configs (ESLint rule names use kebab-case) + '@typescript-eslint/naming-convention': 'off', + // Allow default exports for ESLint plugin + 'import-x/no-default-export': 'off', + }, + }, +]); diff --git a/packages/@n8n/eslint-plugin-community-nodes/package.json b/packages/@n8n/eslint-plugin-community-nodes/package.json index 72ae6b7ec96..c42355b7a37 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/package.json +++ b/packages/@n8n/eslint-plugin-community-nodes/package.json @@ -1,7 +1,9 @@ { "name": "@n8n/eslint-plugin-community-nodes", "type": "module", - "version": "0.4.0", + "version": "0.6.0", + "main": "./dist/plugin.js", + "types": "./dist/plugin.d.ts", "exports": { ".": { "types": "./dist/plugin.d.ts", @@ -9,28 +11,47 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc --project tsconfig.build.json", + "build:docs": "eslint-doc-generator", "clean": "rimraf dist .turbo", "dev": "pnpm watch", "format": "biome format --write .", "format:check": "biome ci .", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "lint:docs": "eslint-doc-generator --check", "test": "vitest run", "test:dev": "vitest", "typecheck": "tsc --noEmit", - "watch": "tsc --watch" + "watch": "tsc --watch --project tsconfig.build.json" }, "dependencies": { - "@typescript-eslint/utils": "^8.35.0" + "@typescript-eslint/utils": "^8.35.0", + "fastest-levenshtein": "catalog:" }, "devDependencies": { "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@typescript-eslint/rule-tester": "^8.35.0", + "eslint-doc-generator": "^2.2.2", + "eslint-plugin-eslint-plugin": "^7.0.0", "rimraf": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, "peerDependencies": { "eslint": ">= 9" + }, + "eslint-doc-generator": { + "configEmoji": [ + [ + "recommended", + "✅" + ], + [ + "recommendedWithoutN8nCloudSupport", + "☑️" + ] + ] } } diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts index b51c697ecdb..288154d1bcf 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts @@ -1,14 +1,13 @@ import type { ESLint, Linter } from 'eslint'; -import { rules } from './rules/index.js'; -import fs from 'node:fs'; -const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +import pkg from '../package.json' with { type: 'json' }; +import { rules } from './rules/index.js'; const plugin = { meta: { name: pkg.name, version: pkg.version, - namespace: 'n8n-community-nodes', + namespace: '@n8n/community-nodes', }, // @ts-expect-error Rules type does not match for typescript-eslint and eslint rules: rules as ESLint.Plugin['rules'], @@ -18,38 +17,43 @@ const configs = { recommended: { ignores: ['eslint.config.{js,mjs,ts,mts}'], plugins: { - 'n8n-community-nodes': plugin, + '@n8n/community-nodes': plugin, }, rules: { - 'n8n-community-nodes/no-restricted-globals': 'error', - 'n8n-community-nodes/no-restricted-imports': 'error', - 'n8n-community-nodes/credential-password-field': 'error', - 'n8n-community-nodes/no-deprecated-workflow-functions': 'error', - 'n8n-community-nodes/node-usable-as-tool': 'error', - 'n8n-community-nodes/package-name-convention': 'error', - 'n8n-community-nodes/credential-test-required': 'error', - 'n8n-community-nodes/no-credential-reuse': 'error', - 'n8n-community-nodes/icon-validation': 'error', - 'n8n-community-nodes/resource-operation-pattern': 'warn', + '@n8n/community-nodes/no-restricted-globals': 'error', + '@n8n/community-nodes/no-restricted-imports': 'error', + '@n8n/community-nodes/credential-password-field': 'error', + '@n8n/community-nodes/no-deprecated-workflow-functions': 'error', + '@n8n/community-nodes/node-usable-as-tool': 'error', + '@n8n/community-nodes/package-name-convention': 'error', + '@n8n/community-nodes/credential-test-required': 'error', + '@n8n/community-nodes/no-credential-reuse': 'error', + '@n8n/community-nodes/icon-validation': 'error', + '@n8n/community-nodes/resource-operation-pattern': 'warn', + '@n8n/community-nodes/credential-documentation-url': 'error', }, }, recommendedWithoutN8nCloudSupport: { ignores: ['eslint.config.{js,mjs,ts,mts}'], plugins: { - 'n8n-community-nodes': plugin, + '@n8n/community-nodes': plugin, }, rules: { - 'n8n-community-nodes/credential-password-field': 'error', - 'n8n-community-nodes/no-deprecated-workflow-functions': 'error', - 'n8n-community-nodes/node-usable-as-tool': 'error', - 'n8n-community-nodes/package-name-convention': 'error', - 'n8n-community-nodes/credential-test-required': 'error', - 'n8n-community-nodes/no-credential-reuse': 'error', - 'n8n-community-nodes/icon-validation': 'error', - 'n8n-community-nodes/resource-operation-pattern': 'warn', + '@n8n/community-nodes/credential-password-field': 'error', + '@n8n/community-nodes/no-deprecated-workflow-functions': 'error', + '@n8n/community-nodes/node-usable-as-tool': 'error', + '@n8n/community-nodes/package-name-convention': 'error', + '@n8n/community-nodes/credential-test-required': 'error', + '@n8n/community-nodes/no-credential-reuse': 'error', + '@n8n/community-nodes/icon-validation': 'error', + '@n8n/community-nodes/credential-documentation-url': 'error', + '@n8n/community-nodes/resource-operation-pattern': 'warn', }, }, } satisfies Record; -export const n8nCommunityNodesPlugin = { ...plugin, configs } satisfies ESLint.Plugin; -export default n8nCommunityNodesPlugin; +const pluginWithConfigs = { ...plugin, configs } satisfies ESLint.Plugin; + +const n8nCommunityNodesPlugin = pluginWithConfigs; +export default pluginWithConfigs; +export { rules, configs, n8nCommunityNodesPlugin }; diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-documentation-url.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-documentation-url.test.ts new file mode 100644 index 00000000000..0a40b4b9473 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-documentation-url.test.ts @@ -0,0 +1,306 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { CredentialDocumentationUrlRule } from './credential-documentation-url.js'; + +const ruleTester = new RuleTester(); + +function createCredentialCode(documentationUrl: string): string { + return ` +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class TestCredential implements ICredentialType { + name = 'testApi'; + displayName = 'Test API'; + documentationUrl = '${documentationUrl}'; + + properties: INodeProperties[] = []; +}`; +} + +function createCredentialWithoutDocUrl(): string { + return ` +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class TestCredential implements ICredentialType { + name = 'testApi'; + displayName = 'Test API'; + + properties: INodeProperties[] = []; +}`; +} + +function createRegularClass(): string { + return ` +export class RegularClass { + documentationUrl = 'invalid-url'; +}`; +} + +ruleTester.run('credential-documentation-url', CredentialDocumentationUrlRule, { + valid: [ + { + name: 'valid URL with default options (URLs only)', + code: createCredentialCode('https://example.com/docs'), + }, + { + name: 'valid URL with explicit options', + code: createCredentialCode('https://example.com/docs'), + options: [{ allowUrls: true, allowSlugs: false }], + }, + { + name: 'valid lowercase slug when slugs are allowed', + code: createCredentialCode('myservice'), + options: [{ allowUrls: false, allowSlugs: true }], + }, + { + name: 'valid lowercase slug with slashes when slugs are allowed', + code: createCredentialCode('myservice/advanced/config'), + options: [{ allowUrls: false, allowSlugs: true }], + }, + { + name: 'valid URL when both URLs and slugs are allowed', + code: createCredentialCode('https://example.com/docs'), + options: [{ allowUrls: true, allowSlugs: true }], + }, + { + name: 'valid lowercase slug when both URLs and slugs are allowed', + code: createCredentialCode('myservice/config'), + options: [{ allowUrls: true, allowSlugs: true }], + }, + { + name: 'credential without documentationUrl should not trigger', + code: createCredentialWithoutDocUrl(), + }, + { + name: 'class not implementing ICredentialType should be ignored', + code: createRegularClass(), + }, + { + name: 'valid lowercase slug with multiple segments', + code: createCredentialCode('myservice/somefeature/advancedconfig'), + options: [{ allowUrls: false, allowSlugs: true }], + }, + { + name: 'valid lowercase alphanumeric slug', + code: createCredentialCode('myservice123'), + options: [{ allowUrls: false, allowSlugs: true }], + }, + { + name: 'valid lowercase alphanumeric slug with slashes', + code: createCredentialCode('myservice123/config456'), + options: [{ allowUrls: false, allowSlugs: true }], + }, + ], + invalid: [ + { + name: 'invalid URL with default options', + code: createCredentialCode('invalid-url'), + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'invalid-url', + expectedFormats: 'a valid URL', + }, + }, + ], + }, + { + name: 'slug not allowed with default options', + code: createCredentialCode('myservice'), + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'myservice', + expectedFormats: 'a valid URL', + }, + }, + ], + }, + { + name: 'slug with special characters should not be autofixable', + code: createCredentialCode('My-Service'), + options: [{ allowUrls: false, allowSlugs: true }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'My-Service', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'uppercase slug should be autofixable', + code: createCredentialCode('MyService'), + options: [{ allowUrls: false, allowSlugs: true }], + output: createCredentialCode('myservice'), + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'MyService', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'invalid URL when only URLs are allowed', + code: createCredentialCode('not-a-valid-url'), + options: [{ allowUrls: true, allowSlugs: false }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'not-a-valid-url', + expectedFormats: 'a valid URL', + }, + }, + ], + }, + { + name: 'invalid when neither URLs nor slugs are allowed', + code: createCredentialCode('https://example.com'), + options: [{ allowUrls: false, allowSlugs: false }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'https://example.com', + expectedFormats: 'a valid format (none configured)', + }, + }, + ], + }, + { + name: 'slug with invalid characters (special chars) should not be autofixable', + code: createCredentialCode('my@service/config'), + options: [{ allowUrls: false, allowSlugs: true }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'my@service/config', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'slug with uppercase segment should be autofixable', + code: createCredentialCode('myService/Config'), + options: [{ allowUrls: false, allowSlugs: true }], + output: createCredentialCode('myservice/config'), + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'myService/Config', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'slug with hyphens should not be autofixable', + code: createCredentialCode('myservice/advanced-config'), + options: [{ allowUrls: false, allowSlugs: true }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'myservice/advanced-config', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'slug with underscores should not be autofixable', + code: createCredentialCode('my_service/config_advanced'), + options: [{ allowUrls: false, allowSlugs: true }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'my_service/config_advanced', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'invalid value when both formats are allowed - shows both in error message', + code: createCredentialCode('Invalid-Value!'), + options: [{ allowUrls: true, allowSlugs: true }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'Invalid-Value!', + expectedFormats: 'a valid URL or a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'empty string should be invalid with default options', + code: createCredentialCode(''), + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: '', + expectedFormats: 'a valid URL', + }, + }, + ], + }, + { + name: 'empty string should be invalid when slugs are allowed', + code: createCredentialCode(''), + options: [{ allowUrls: false, allowSlugs: true }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: '', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'mixed case slug with numbers should be autofixable', + code: createCredentialCode('MyService123/Config456'), + options: [{ allowUrls: false, allowSlugs: true }], + output: createCredentialCode('myservice123/config456'), + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: 'MyService123/Config456', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + { + name: 'slug starting with number should be invalid and not autofixable', + code: createCredentialCode('123service/config'), + options: [{ allowUrls: false, allowSlugs: true }], + errors: [ + { + messageId: 'invalidDocumentationUrl', + data: { + value: '123service/config', + expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)', + }, + }, + ], + }, + ], +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-documentation-url.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-documentation-url.ts new file mode 100644 index 00000000000..eb95293f060 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-documentation-url.ts @@ -0,0 +1,129 @@ +import { + isCredentialTypeClass, + findClassProperty, + getStringLiteralValue, + createRule, +} from '../utils/index.js'; + +type RuleOptions = { + allowUrls?: boolean; + allowSlugs?: boolean; +}; + +const DEFAULT_OPTIONS: RuleOptions = { + allowUrls: true, + allowSlugs: false, +}; + +function isValidUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } +} + +function isValidSlug(value: string): boolean { + // TODO: Remove this special case once these slugs are updated + if ( + ['google/service-account', 'google/oauth-single-service', 'google/oauth-generic'].includes( + value, + ) + ) + return true; + + return value.split('/').every((segment) => /^[a-z][a-z0-9]*$/.test(segment)); +} + +function hasOnlyCaseIssues(value: string): boolean { + return value.split('/').every((segment) => /^[a-zA-Z][a-zA-Z0-9]*$/.test(segment)); +} + +function validateDocumentationUrl(value: string, options: RuleOptions): boolean { + return (!!options.allowUrls && isValidUrl(value)) || (!!options.allowSlugs && isValidSlug(value)); +} + +function getExpectedFormatsMessage(options: RuleOptions): string { + const formats = [ + ...(options.allowUrls ? ['a valid URL'] : []), + ...(options.allowSlugs ? ['a lowercase alphanumeric slug (can contain slashes)'] : []), + ]; + + if (formats.length === 0) return 'a valid format (none configured)'; + if (formats.length === 1) return formats[0]!; + return formats.slice(0, -1).join(', ') + ' or ' + formats[formats.length - 1]; +} + +export const CredentialDocumentationUrlRule = createRule({ + name: 'credential-documentation-url', + meta: { + type: 'problem', + docs: { + description: + 'Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug)', + }, + messages: { + invalidDocumentationUrl: "documentationUrl '{{ value }}' must be {{ expectedFormats }}", + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + allowUrls: { + type: 'boolean', + description: 'Whether to allow valid URLs', + }, + allowSlugs: { + type: 'boolean', + description: 'Whether to allow lowercase alphanumeric slugs with slashes', + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [DEFAULT_OPTIONS], + create(context, [options = {}]) { + const mergedOptions = { ...DEFAULT_OPTIONS, ...options }; + + return { + ClassDeclaration(node) { + if (!isCredentialTypeClass(node)) { + return; + } + + const documentationUrlProperty = findClassProperty(node, 'documentationUrl'); + if (!documentationUrlProperty?.value) { + return; + } + + const documentationUrl = getStringLiteralValue(documentationUrlProperty.value); + if (documentationUrl === null) { + return; + } + + if (!validateDocumentationUrl(documentationUrl, mergedOptions)) { + const canAutofix = !!mergedOptions.allowSlugs && hasOnlyCaseIssues(documentationUrl); + + context.report({ + node: documentationUrlProperty.value, + messageId: 'invalidDocumentationUrl', + data: { + value: documentationUrl, + expectedFormats: getExpectedFormatsMessage(mergedOptions), + }, + fix: canAutofix + ? (fixer) => + fixer.replaceText( + documentationUrlProperty.value!, + `'${documentationUrl.toLowerCase()}'`, + ) + : undefined, + }); + } + }, + }; + }, +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.test.ts index b7da9c04f35..129984279d9 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { CredentialPasswordFieldRule } from './credential-password-field.js'; const ruleTester = new RuleTester(); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.ts index 9264e1d2b74..5e11296945f 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-password-field.ts @@ -1,10 +1,13 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESTree } from '@typescript-eslint/types'; +import type { ReportFixFunction } from '@typescript-eslint/utils/ts-eslint'; + import { isCredentialTypeClass, findClassProperty, findObjectProperty, getStringLiteralValue, getBooleanLiteralValue, + createRule, } from '../utils/index.js'; const SENSITIVE_PATTERNS = [ @@ -31,10 +34,10 @@ function isSensitiveFieldName(name: string): boolean { return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern)); } -function hasPasswordTypeOption(element: any): boolean { +function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean { const typeOptionsProperty = findObjectProperty(element, 'typeOptions'); - if (typeOptionsProperty?.value?.type !== 'ObjectExpression') { + if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) { return false; } @@ -44,31 +47,43 @@ function hasPasswordTypeOption(element: any): boolean { return passwordValue === true; } -function createPasswordFix(element: any, typeOptionsProperty: any) { - return function (fixer: any) { - if (typeOptionsProperty?.value?.type === 'ObjectExpression') { +function createPasswordFix( + element: TSESTree.ObjectExpression, + typeOptionsProperty: TSESTree.Property | null, +): ReportFixFunction { + return (fixer) => { + if (typeOptionsProperty?.value.type === TSESTree.AST_NODE_TYPES.ObjectExpression) { const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password'); if (passwordProperty) { return fixer.replaceText(passwordProperty.value, 'true'); } - if (typeOptionsProperty.value.properties.length > 0) { - const lastProperty = - typeOptionsProperty.value.properties[typeOptionsProperty.value.properties.length - 1]; - return lastProperty ? fixer.insertTextAfter(lastProperty, ', password: true') : null; + const objectValue = typeOptionsProperty.value; + if (objectValue.properties.length > 0) { + const lastProperty = objectValue.properties[objectValue.properties.length - 1]; + if (lastProperty) { + return fixer.insertTextAfter(lastProperty, ', password: true'); + } } else { - const openBrace = typeOptionsProperty.value.range![0] + 1; - return fixer.insertTextAfterRange([openBrace, openBrace], ' password: true '); + const range = objectValue.range; + if (range) { + const openBrace = range[0] + 1; + return fixer.insertTextAfterRange([openBrace, openBrace], ' password: true '); + } } } const lastProperty = element.properties[element.properties.length - 1]; - return fixer.insertTextAfter(lastProperty, ',\n\t\t\ttypeOptions: { password: true }'); + if (lastProperty) { + return fixer.insertTextAfter(lastProperty, ',\n\t\t\ttypeOptions: { password: true }'); + } + return null; }; } -export const CredentialPasswordFieldRule = ESLintUtils.RuleCreator.withoutDocs({ +export const CredentialPasswordFieldRule = createRule({ + name: 'credential-password-field', meta: { type: 'problem', docs: { @@ -90,12 +105,15 @@ export const CredentialPasswordFieldRule = ESLintUtils.RuleCreator.withoutDocs({ } const propertiesProperty = findClassProperty(node, 'properties'); - if (!propertiesProperty?.value || propertiesProperty.value.type !== 'ArrayExpression') { + if ( + !propertiesProperty?.value || + propertiesProperty.value.type !== TSESTree.AST_NODE_TYPES.ArrayExpression + ) { return; } for (const element of propertiesProperty.value.elements) { - if (element?.type !== 'ObjectExpression') { + if (element?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) { continue; } diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.test.ts index f03171dd603..ee771c31a0b 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { CredentialTestRequiredRule } from './credential-test-required.js'; const ruleTester = new RuleTester(); @@ -20,13 +21,13 @@ function createCredentialCode(options: { } = options; const imports = hasTest - ? `import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';` - : `import type { ICredentialType, INodeProperties } from 'n8n-workflow';`; + ? "import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';" + : "import type { ICredentialType, INodeProperties } from 'n8n-workflow';"; const extendsStr = extendsArray ? `\n\textends = ${JSON.stringify(extendsArray)};` : ''; const testProperty = hasTest - ? `\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};` + ? "\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};" : ''; return ` @@ -46,57 +47,6 @@ export class ${className} { }`; } -function createNodeCode(options: { - name?: string; - displayName?: string; - credentials?: Array; - extendsClass?: string; -}): string { - const { name = 'myNode', displayName = 'My Node', credentials = [], extendsClass } = options; - - const credentialsArray = credentials - .map((cred) => { - if (typeof cred === 'string') { - return `'${cred}'`; - } - const testedByStr = cred.testedBy ? `, testedBy: '${cred.testedBy}'` : ''; - return `{ name: '${cred.name}'${testedByStr} }`; - }) - .join(', '); - - const classDeclaration = extendsClass - ? `export class ${name.charAt(0).toUpperCase() + name.slice(1)} extends ${extendsClass}` - : `export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements INodeType`; - - const descriptionProperty = extendsClass - ? `description = {` // Extending classes might not need full INodeTypeDescription - : `description: INodeTypeDescription = {`; - - return ` -import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; - -${classDeclaration} { - ${descriptionProperty} - displayName: '${displayName}', - name: '${name}', - group: ['transform'], - version: 1, - description: 'A test node', - defaults: { - name: '${displayName}', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [${credentialsArray}], - properties: [], - }; - - execute() { - return Promise.resolve([]); - } -}`; -} - ruleTester.run('credential-test-required', CredentialTestRequiredRule, { valid: [ { @@ -129,19 +79,96 @@ ruleTester.run('credential-test-required', CredentialTestRequiredRule, { name: 'credential class missing test property and no testedBy in package', filename: 'MyApi.credentials.ts', code: createCredentialCode({}), - errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }], + errors: [ + { + messageId: 'missingCredentialTest', + data: { className: 'MyApi' }, + suggestions: [ + { + messageId: 'addTemplate', + output: ` +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class MyApi implements ICredentialType { + name = 'myApi'; + displayName = 'My API'; + properties: INodeProperties[] = []; + + test: ICredentialTestRequest = { + request: { + method: 'GET', + url: '={{$credentials.server}}/test', // Replace with actual endpoint + }, + }; +}`, + }, + ], + }, + ], }, { name: 'credential class with extends but not oAuth2Api and no testedBy in package', filename: 'MyApi.credentials.ts', code: createCredentialCode({ extends: ['someOtherApi'] }), - errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }], + errors: [ + { + messageId: 'missingCredentialTest', + data: { className: 'MyApi' }, + suggestions: [ + { + messageId: 'addTemplate', + output: ` +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class MyApi implements ICredentialType { + name = 'myApi'; + extends = ["someOtherApi"]; + displayName = 'My API'; + properties: INodeProperties[] = []; + + test: ICredentialTestRequest = { + request: { + method: 'GET', + url: '={{$credentials.server}}/test', // Replace with actual endpoint + }, + }; +}`, + }, + ], + }, + ], }, { name: 'credential class with empty extends array and no testedBy in package', filename: 'MyApi.credentials.ts', code: createCredentialCode({ extends: [] }), - errors: [{ messageId: 'missingCredentialTest', data: { className: 'MyApi' } }], + errors: [ + { + messageId: 'missingCredentialTest', + data: { className: 'MyApi' }, + suggestions: [ + { + messageId: 'addTemplate', + output: ` +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class MyApi implements ICredentialType { + name = 'myApi'; + extends = []; + displayName = 'My API'; + properties: INodeProperties[] = []; + + test: ICredentialTestRequest = { + request: { + method: 'GET', + url: '={{$credentials.server}}/test', // Replace with actual endpoint + }, + }; +}`, + }, + ], + }, + ], }, ], }); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.ts index d97f5a9ea08..6e7490e7cf1 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/credential-test-required.ts @@ -1,4 +1,6 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint'; +import { dirname } from 'node:path'; + import { isCredentialTypeClass, findClassProperty, @@ -7,20 +9,23 @@ import { getStringLiteralValue, findPackageJson, areAllCredentialUsagesTestedByNodes, + createRule, } from '../utils/index.js'; -import { dirname } from 'node:path'; -export const CredentialTestRequiredRule = ESLintUtils.RuleCreator.withoutDocs({ +export const CredentialTestRequiredRule = createRule({ + name: 'credential-test-required', meta: { type: 'problem', docs: { description: 'Ensure credentials have a credential test', }, messages: { + addTemplate: 'Add basic credential test template', missingCredentialTest: 'Credential class "{{ className }}" must have a test property or be tested by a node via testedBy', }, schema: [], + hasSuggestions: true, }, defaultOptions: [], create(context) { @@ -73,27 +78,68 @@ export const CredentialTestRequiredRule = ESLintUtils.RuleCreator.withoutDocs({ const pkgDir = getPackageDir(); if (!pkgDir) { + const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = []; + + const testProperty = createCredentialTestTemplate(); + suggestions.push({ + messageId: 'addTemplate', + fix(fixer) { + const classBody = node.body.body; + const lastProperty = classBody[classBody.length - 1]; + if (lastProperty) { + return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`); + } + return null; + }, + }); + context.report({ node, messageId: 'missingCredentialTest', data: { - className: node.id?.name || 'Unknown', + className: node.id?.name ?? 'Unknown', }, + suggest: suggestions, }); return; } const allUsagesTestedByNodes = areAllCredentialUsagesTestedByNodes(credentialName, pkgDir); if (!allUsagesTestedByNodes) { + const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = []; + + const testProperty = createCredentialTestTemplate(); + suggestions.push({ + messageId: 'addTemplate', + fix(fixer) { + const classBody = node.body.body; + const lastProperty = classBody[classBody.length - 1]; + if (lastProperty) { + return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`); + } + return null; + }, + }); + context.report({ node, messageId: 'missingCredentialTest', data: { - className: node.id?.name || 'Unknown', + className: node.id?.name ?? 'Unknown', }, + suggest: suggestions, }); } }, }; }, }); + +function createCredentialTestTemplate(): string { + return `\ttest: ICredentialTestRequest = { +\t\trequest: { +\t\t\tmethod: 'GET', +\t\t\turl: '={{$credentials.server}}/test', // Replace with actual endpoint +\t\t}, +\t};`; +} diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.test.ts index 2e4fd9f9806..f2f23b33281 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.test.ts @@ -1,26 +1,51 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; -import { IconValidationRule } from './icon-validation.js'; -import { vi } from 'vitest'; import * as fs from 'node:fs'; +import { vi } from 'vitest'; + +import { IconValidationRule } from './icon-validation.js'; const ruleTester = new RuleTester(); vi.mock('node:fs', () => ({ existsSync: vi.fn(), + readdirSync: vi.fn(), })); const mockExistsSync = vi.mocked(fs.existsSync); +const mockReaddirSync = vi.mocked(fs.readdirSync); + +const mockSvgFiles = [ + 'TestNode.svg', + 'ValidIcon.svg', + 'ValidIcon.dark.svg', + 'SameIcon.svg', + 'github.svg', +]; function setupMockFileSystem() { mockExistsSync.mockImplementation((path: fs.PathLike) => { const pathStr = path.toString(); - return ( - pathStr.includes('TestNode.svg') || - pathStr.includes('ValidIcon.svg') || - pathStr.includes('ValidIcon.dark.svg') || - pathStr.includes('SameIcon.svg') || - pathStr.includes('NotSvg.png') - ); + + if (mockSvgFiles.some((file) => pathStr.includes(file)) || pathStr.includes('NotSvg.png')) { + return true; + } + + if (pathStr.endsWith('/tmp/icons') || pathStr.endsWith('/tmp') || pathStr.endsWith('icons')) { + return true; + } + + return false; + }); + + // @ts-expect-error Typescript does not select the correct overload + mockReaddirSync.mockImplementation((path: fs.PathLike): string[] => { + const pathStr = path.toString(); + + if (pathStr.includes('icons')) { + return [...mockSvgFiles, 'NotSvg.png']; + } + + return []; }); } @@ -34,10 +59,10 @@ function createNodeCode( includeTypeImport: boolean = false, ): string { const typeImport = includeTypeImport - ? `import type { INodeType, INodeTypeDescription } from 'n8n-workflow';` - : `import type { INodeType } from 'n8n-workflow';`; + ? "import type { INodeType, INodeTypeDescription } from 'n8n-workflow';" + : "import type { INodeType } from 'n8n-workflow';"; - const typeAnnotation = includeTypeImport ? `: INodeTypeDescription` : ''; + const typeAnnotation = includeTypeImport ? ': INodeTypeDescription' : ''; let iconProperty = ''; if (icon) { @@ -151,7 +176,18 @@ ruleTester.run('icon-validation', IconValidationRule, { name: 'node missing icon property in description', filename: nodeFilePath, code: createNodeCode(undefined, true), - errors: [{ messageId: 'missingIcon' }], + errors: [ + { + messageId: 'missingIcon', + suggestions: [ + { + messageId: 'addPlaceholder', + output: + "\nimport type { INodeType, INodeTypeDescription } from 'n8n-workflow';\n\nexport class TestNode implements INodeType {\n\tdescription: INodeTypeDescription = {\n\t\tdisplayName: 'Test Node',\n\t\tname: 'testNode',\n\t\t\n\t\tgroup: ['input'],\n\t\tversion: 1,\n\t\tdescription: 'A test node',\n\t\tdefaults: {\n\t\t\tname: 'Test Node',\n\t\t},\n\t\tinputs: ['main'],\n\t\toutputs: ['main'],\n\t\tproperties: [],\n\t\ticon: \"file:./icon.svg\",\n\t};\n}", + }, + ], + }, + ], }, { name: 'icon file does not exist in description', @@ -175,7 +211,18 @@ ruleTester.run('icon-validation', IconValidationRule, { name: 'credential missing icon property', filename: credentialFilePath, code: createCredentialCode(), - errors: [{ messageId: 'missingIcon' }], + errors: [ + { + messageId: 'missingIcon', + suggestions: [ + { + messageId: 'addPlaceholder', + output: + "\nimport type { ICredentialType, INodeProperties } from 'n8n-workflow';\n\nexport class TestCredential implements ICredentialType {\n\tname = 'testApi';\n\tdisplayName = 'Test API';\n\t\n\tproperties: INodeProperties[] = [];\n\n\ticon = \"file:./icon.svg\";\n}", + }, + ], + }, + ], }, { name: 'credential icon file does not exist', @@ -192,5 +239,41 @@ ruleTester.run('icon-validation', IconValidationRule, { }), errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }], }, + { + name: 'node icon file does not exist but similar file exists - should suggest similar file', + filename: nodeFilePath, + code: createNodeCode('file:icons/github2.svg'), + errors: [ + { + messageId: 'iconFileNotFound', + data: { iconPath: 'icons/github2.svg' }, + suggestions: [ + { + messageId: 'similarIcon', + data: { suggestedName: 'icons/github.svg' }, + output: ` +import type { INodeType } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description = { + displayName: 'Test Node', + name: 'testNode', + icon: "file:icons/github.svg", + group: ['input'], + version: 1, + description: 'A test node', + defaults: { + name: 'Test Node', + }, + inputs: ['main'], + outputs: ['main'], + properties: [], + }; +}`, + }, + ], + }, + ], + }, ], }); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.ts index 37b462c95cb..480ed3b0bfa 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/icon-validation.ts @@ -1,5 +1,7 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import { TSESTree } from '@typescript-eslint/utils'; +import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint'; import { dirname } from 'node:path'; + import { isNodeTypeClass, isCredentialTypeClass, @@ -7,24 +9,34 @@ import { findObjectProperty, getStringLiteralValue, validateIconPath, + findSimilarSvgFiles, isFileType, + createRule, } from '../utils/index.js'; -export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({ +const messages = { + iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist', + iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)', + lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"', + invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string', + missingIcon: 'Node/Credential class must have an icon property defined', + addPlaceholder: 'Add icon property with placeholder', + addFileProtocol: "Add 'file:' protocol to icon path", + changeExtension: "Change icon extension to '.svg'", + similarIcon: "Use existing icon '{{ suggestedName }}'", +} as const; + +export const IconValidationRule = createRule({ + name: 'icon-validation', meta: { type: 'problem', docs: { description: 'Validate node and credential icon files exist, are SVG format, and light/dark icons are different', }, - messages: { - iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist', - iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)', - lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"', - invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string', - missingIcon: 'Node/Credential class must have an icon property defined', - }, + messages, schema: [], + hasSuggestions: true, }, defaultOptions: [], create(context) { @@ -40,7 +52,7 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({ context.report({ node, messageId: 'invalidIconPath', - data: { iconPath: iconPath || '' }, + data: { iconPath: iconPath ?? '' }, }); return false; } @@ -49,30 +61,68 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({ const validation = validateIconPath(iconPath, currentDir); if (!validation.isFile) { + const suggestions: ReportSuggestionArray = []; + if (!iconPath.startsWith('file:')) { + suggestions.push({ + messageId: 'addFileProtocol', + fix(fixer) { + return fixer.replaceText(node, `"file:${iconPath}"`); + }, + }); + } + context.report({ node, messageId: 'invalidIconPath', data: { iconPath }, + suggest: suggestions, }); return false; } if (!validation.isSvg) { const relativePath = iconPath.replace(/^file:/, ''); + const suggestions: ReportSuggestionArray = []; + + const pathWithoutExt = relativePath.replace(/\.[^/.]+$/, ''); + const svgPath = `${pathWithoutExt}.svg`; + suggestions.push({ + messageId: 'changeExtension', + fix(fixer) { + return fixer.replaceText(node, `"file:${svgPath}"`); + }, + }); + context.report({ node, messageId: 'iconNotSvg', data: { iconPath: relativePath }, + suggest: suggestions, }); return false; } if (!validation.exists) { const relativePath = iconPath.replace(/^file:/, ''); + const suggestions: ReportSuggestionArray = []; + + // Find similar SVG files in the same directory + const similarFiles = findSimilarSvgFiles(relativePath, currentDir); + for (const similarFile of similarFiles) { + suggestions.push({ + messageId: 'similarIcon', + data: { suggestedName: similarFile }, + fix(fixer) { + return fixer.replaceText(node, `"file:${similarFile}"`); + }, + }); + } + context.report({ node, messageId: 'iconFileNotFound', data: { iconPath: relativePath }, + suggest: suggestions, }); return false; } @@ -81,10 +131,10 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({ }; const validateIconValue = (iconValue: TSESTree.Node) => { - if (iconValue.type === 'Literal') { + if (iconValue.type === TSESTree.AST_NODE_TYPES.Literal) { const iconPath = getStringLiteralValue(iconValue); validateIcon(iconPath, iconValue); - } else if (iconValue.type === 'ObjectExpression') { + } else if (iconValue.type === TSESTree.AST_NODE_TYPES.ObjectExpression) { const lightProperty = findObjectProperty(iconValue, 'light'); const darkProperty = findObjectProperty(iconValue, 'dark'); @@ -121,7 +171,7 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({ const descriptionProperty = findClassProperty(node, 'description'); if ( !descriptionProperty?.value || - descriptionProperty.value.type !== 'ObjectExpression' + descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression ) { context.report({ node, @@ -130,11 +180,27 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({ return; } - const iconProperty = findObjectProperty(descriptionProperty.value, 'icon'); + const descriptionValue = descriptionProperty.value; + const iconProperty = findObjectProperty(descriptionValue, 'icon'); if (!iconProperty) { + const suggestions: ReportSuggestionArray = []; + + suggestions.push({ + messageId: 'addPlaceholder', + fix(fixer) { + const lastProperty = + descriptionValue.properties[descriptionValue.properties.length - 1]; + if (lastProperty) { + return fixer.insertTextAfter(lastProperty, ',\n\t\ticon: "file:./icon.svg"'); + } + return null; + }, + }); + context.report({ node, messageId: 'missingIcon', + suggest: suggestions, }); return; } @@ -143,9 +209,24 @@ export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({ } else if (isCredentialClass) { const iconProperty = findClassProperty(node, 'icon'); if (!iconProperty?.value) { + const suggestions: ReportSuggestionArray = []; + + suggestions.push({ + messageId: 'addPlaceholder', + fix(fixer) { + const classBody = node.body.body; + const lastProperty = classBody[classBody.length - 1]; + if (lastProperty) { + return fixer.insertTextAfter(lastProperty, '\n\n\ticon = "file:./icon.svg";'); + } + return null; + }, + }); + context.report({ node, messageId: 'missingIcon', + suggest: suggestions, }); return; } diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts index 70bcf311684..eb85abaec9c 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts @@ -1,13 +1,15 @@ import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint'; + +import { CredentialDocumentationUrlRule } from './credential-documentation-url.js'; +import { CredentialPasswordFieldRule } from './credential-password-field.js'; +import { CredentialTestRequiredRule } from './credential-test-required.js'; +import { IconValidationRule } from './icon-validation.js'; +import { NoCredentialReuseRule } from './no-credential-reuse.js'; +import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js'; import { NoRestrictedGlobalsRule } from './no-restricted-globals.js'; import { NoRestrictedImportsRule } from './no-restricted-imports.js'; -import { CredentialPasswordFieldRule } from './credential-password-field.js'; -import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js'; import { NodeUsableAsToolRule } from './node-usable-as-tool.js'; import { PackageNameConventionRule } from './package-name-convention.js'; -import { CredentialTestRequiredRule } from './credential-test-required.js'; -import { NoCredentialReuseRule } from './no-credential-reuse.js'; -import { IconValidationRule } from './icon-validation.js'; import { ResourceOperationPatternRule } from './resource-operation-pattern.js'; export const rules = { @@ -21,4 +23,5 @@ export const rules = { 'no-credential-reuse': NoCredentialReuseRule, 'icon-validation': IconValidationRule, 'resource-operation-pattern': ResourceOperationPatternRule, + 'credential-documentation-url': CredentialDocumentationUrlRule, } satisfies Record; diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.test.ts index 0bbe0f88182..e0284a88911 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.test.ts @@ -1,35 +1,27 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; -import { NoCredentialReuseRule } from './no-credential-reuse.js'; import { vi } from 'vitest'; -import * as fs from 'node:fs'; + +import { NoCredentialReuseRule } from './no-credential-reuse.js'; +import * as fileUtils from '../utils/file-utils.js'; + +vi.mock('../utils/file-utils.js', async () => { + const actual = await vi.importActual('../utils/file-utils.js'); + return { + ...actual, + readPackageJsonCredentials: vi.fn(), + findPackageJson: vi.fn(), + }; +}); + +const mockReadPackageJsonCredentials = vi.mocked(fileUtils.readPackageJsonCredentials); +const mockFindPackageJson = vi.mocked(fileUtils.findPackageJson); const ruleTester = new RuleTester(); -// Mock fs functions -vi.mock('node:fs', () => ({ - readFileSync: vi.fn(), - existsSync: vi.fn(), -})); - -const mockReadFileSync = vi.mocked(fs.readFileSync); -const mockExistsSync = vi.mocked(fs.existsSync); - const nodeFilePath = '/tmp/TestNode.node.ts'; -function createCredentialClass(name: string, displayName: string): string { - return ` - import type { ICredentialType, INodeProperties } from 'n8n-workflow'; - - export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements ICredentialType { - name = '${name}'; - displayName = '${displayName}'; - properties: INodeProperties[] = []; - } - `; -} - function createNodeCode( - credentials: (string | { name: string; required?: boolean })[] = [], + credentials: Array = [], ): string { const credentialsArray = credentials.length > 0 @@ -66,6 +58,45 @@ export class TestNode implements INodeType { }`; } +// Helper function to create expected outputs with double quotes (matching rule fix behavior) +function createExpectedNodeCode( + credentials: Array = [], +): string { + const credentialsArray = + credentials.length > 0 + ? credentials + .map((cred) => { + if (typeof cred === 'string') { + return `"${cred}"`; + } else { + const required = + cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : ''; + return `{\n\t\t\t\tname: "${cred.name}"${required},\n\t\t\t}`; + } + }) + .join(',\n\t\t\t') + : ''; + + const credentialsProperty = + credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : ''; + + return ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + ${credentialsProperty} + properties: [], + }; +}`; +} + // Helper function to create non-node class function createNonNodeClass(): string { return ` @@ -90,41 +121,10 @@ export class NotANode { } function setupMockFileSystem() { - const packageJson = { - name: 'test-package', - n8n: { - credentials: [ - 'dist/credentials/MyApi.credentials.js', - 'dist/credentials/AnotherApi.credentials.js', - ], - }, - }; - - const myApiCredential = createCredentialClass('myApiCredential', 'My API'); - const anotherApiCredential = createCredentialClass('anotherApiCredential', 'Another API'); - - mockExistsSync.mockImplementation((path: fs.PathLike) => { - const pathStr = path.toString(); - return ( - pathStr.includes('package.json') || - pathStr.includes('MyApi.credentials.ts') || - pathStr.includes('AnotherApi.credentials.ts') - ); - }); - - mockReadFileSync.mockImplementation((path: any): string => { - const pathStr = path.toString(); - if (pathStr.includes('package.json')) { - return JSON.stringify(packageJson, null, 2); - } - if (pathStr.includes('MyApi.credentials.ts')) { - return myApiCredential; - } - if (pathStr.includes('AnotherApi.credentials.ts')) { - return anotherApiCredential; - } - throw new Error(`File not found: ${pathStr}`); - }); + mockFindPackageJson.mockReturnValue('/tmp/package.json'); + mockReadPackageJsonCredentials.mockReturnValue( + new Set(['myApiCredential', 'anotherApiCredential']), + ); } setupMockFileSystem(); @@ -171,6 +171,18 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, { { messageId: 'credentialNotInPackage', data: { credentialName: 'ExternalApi' }, + suggestions: [ + { + messageId: 'useAvailable', + data: { suggestedName: 'myApiCredential' }, + output: createExpectedNodeCode([{ name: 'myApiCredential', required: true }]), + }, + { + messageId: 'useAvailable', + data: { suggestedName: 'anotherApiCredential' }, + output: createExpectedNodeCode([{ name: 'anotherApiCredential', required: true }]), + }, + ], }, ], }, @@ -182,6 +194,18 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, { { messageId: 'credentialNotInPackage', data: { credentialName: 'ExternalApi' }, + suggestions: [ + { + messageId: 'useAvailable', + data: { suggestedName: 'myApiCredential' }, + output: createExpectedNodeCode(['myApiCredential']), + }, + { + messageId: 'useAvailable', + data: { suggestedName: 'anotherApiCredential' }, + output: createExpectedNodeCode(['anotherApiCredential']), + }, + ], }, ], }, @@ -197,10 +221,118 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, { { messageId: 'credentialNotInPackage', data: { credentialName: 'ExternalApi' }, + suggestions: [ + { + messageId: 'useAvailable', + data: { suggestedName: 'myApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + 'myApiCredential', + { + name: "myApiCredential", + required: true, + }, + 'AnotherExternalApi' + ], + properties: [], + }; +}`, + }, + { + messageId: 'useAvailable', + data: { suggestedName: 'anotherApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + 'myApiCredential', + { + name: "anotherApiCredential", + required: true, + }, + 'AnotherExternalApi' + ], + properties: [], + }; +}`, + }, + ], }, { messageId: 'credentialNotInPackage', data: { credentialName: 'AnotherExternalApi' }, + suggestions: [ + { + messageId: 'useAvailable', + data: { suggestedName: 'myApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + 'myApiCredential', + { + name: 'ExternalApi', + required: true, + }, + "myApiCredential" + ], + properties: [], + }; +}`, + }, + { + messageId: 'useAvailable', + data: { suggestedName: 'anotherApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + 'myApiCredential', + { + name: 'ExternalApi', + required: true, + }, + "anotherApiCredential" + ], + properties: [], + }; +}`, + }, + ], }, ], }, @@ -215,10 +347,126 @@ ruleTester.run('no-credential-reuse', NoCredentialReuseRule, { { messageId: 'credentialNotInPackage', data: { credentialName: 'ExternalApi1' }, + suggestions: [ + { + messageId: 'useAvailable', + data: { suggestedName: 'myApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: "myApiCredential", + required: true, + }, + { + name: 'ExternalApi2', + required: false, + } + ], + properties: [], + }; +}`, + }, + { + messageId: 'useAvailable', + data: { suggestedName: 'anotherApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: "anotherApiCredential", + required: true, + }, + { + name: 'ExternalApi2', + required: false, + } + ], + properties: [], + }; +}`, + }, + ], }, { messageId: 'credentialNotInPackage', data: { credentialName: 'ExternalApi2' }, + suggestions: [ + { + messageId: 'useAvailable', + data: { suggestedName: 'myApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'ExternalApi1', + required: true, + }, + { + name: "myApiCredential", + required: false, + } + ], + properties: [], + }; +}`, + }, + { + messageId: 'useAvailable', + data: { suggestedName: 'anotherApiCredential' }, + output: ` +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +export class TestNode implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Node', + name: 'testNode', + group: ['output'], + version: 1, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'ExternalApi1', + required: true, + }, + { + name: "anotherApiCredential", + required: false, + } + ], + properties: [], + }; +}`, + }, + ], }, ], }, diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.ts index 5d6f29577e7..c836bbe5252 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-credential-reuse.ts @@ -1,4 +1,6 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESTree } from '@typescript-eslint/types'; +import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint'; + import { isNodeTypeClass, findClassProperty, @@ -7,9 +9,12 @@ import { findPackageJson, readPackageJsonCredentials, isFileType, + findSimilarStrings, + createRule, } from '../utils/index.js'; -export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({ +export const NoCredentialReuseRule = createRule({ + name: 'no-credential-reuse', meta: { type: 'problem', docs: { @@ -17,10 +22,13 @@ export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({ 'Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package', }, messages: { + didYouMean: "Did you mean '{{ suggestedName }}'?", + useAvailable: "Use available credential '{{ suggestedName }}'", credentialNotInPackage: 'SECURITY: Node references credential "{{ credentialName }}" which is not defined in this package. This creates a security risk as it attempts to reuse credentials from other packages. Nodes can only use credentials from the same package as listed in package.json n8n.credentials field.', }, schema: [], + hasSuggestions: true, }, defaultOptions: [], create(context) { @@ -52,7 +60,10 @@ export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({ } const descriptionProperty = findClassProperty(node, 'description'); - if (!descriptionProperty?.value || descriptionProperty.value.type !== 'ObjectExpression') { + if ( + !descriptionProperty?.value || + descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression + ) { return; } @@ -66,12 +77,41 @@ export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({ credentialsArray.elements.forEach((element) => { const credentialInfo = extractCredentialNameFromArray(element); if (credentialInfo && !allowedCredentials.has(credentialInfo.name)) { + const similarCredentials = findSimilarStrings(credentialInfo.name, allowedCredentials); + const suggestions: ReportSuggestionArray< + 'didYouMean' | 'useAvailable' | 'credentialNotInPackage' + > = []; + + for (const similarName of similarCredentials) { + suggestions.push({ + messageId: 'didYouMean', + data: { suggestedName: similarName }, + fix(fixer) { + return fixer.replaceText(credentialInfo.node, `"${similarName}"`); + }, + }); + } + + if (suggestions.length === 0 && allowedCredentials.size > 0) { + const availableCredentials = Array.from(allowedCredentials).slice(0, 3); + for (const availableName of availableCredentials) { + suggestions.push({ + messageId: 'useAvailable', + data: { suggestedName: availableName }, + fix(fixer) { + return fixer.replaceText(credentialInfo.node, `"${availableName}"`); + }, + }); + } + } + context.report({ node: credentialInfo.node, messageId: 'credentialNotInPackage', data: { credentialName: credentialInfo.name, }, + suggest: suggestions, }); } }); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.test.ts index c7c007cb4b8..9d293075e31 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js'; const ruleTester = new RuleTester(); @@ -60,6 +61,16 @@ const response3 = await this.helpers.requestOAuth2.call(this, 'google', options) { messageId: 'deprecatedRequestFunction', data: { functionName: 'request', replacement: 'httpRequest' }, + suggestions: [ + { + messageId: 'suggestReplaceFunction', + data: { functionName: 'request', replacement: 'httpRequest' }, + output: ` +const response1 = await this.helpers.httpRequest('https://example.com/1'); +const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options); +const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`, + }, + ], }, { messageId: 'deprecatedRequestFunction', @@ -67,10 +78,33 @@ const response3 = await this.helpers.requestOAuth2.call(this, 'google', options) functionName: 'requestWithAuthentication', replacement: 'httpRequestWithAuthentication', }, + suggestions: [ + { + messageId: 'suggestReplaceFunction', + data: { + functionName: 'requestWithAuthentication', + replacement: 'httpRequestWithAuthentication', + }, + output: ` +const response1 = await this.helpers.request('https://example.com/1'); +const response2 = await this.helpers.httpRequestWithAuthentication.call(this, 'oauth', options); +const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`, + }, + ], }, { messageId: 'deprecatedRequestFunction', data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' }, + suggestions: [ + { + messageId: 'suggestReplaceFunction', + data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' }, + output: ` +const response1 = await this.helpers.request('https://example.com/1'); +const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options); +const response3 = await this.helpers.httpRequestWithAuthentication.call(this, 'google', options);`, + }, + ], }, ], }, @@ -86,14 +120,50 @@ function makeRequest(options: IRequestOptions): Promise { { messageId: 'deprecatedType', data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' }, + suggestions: [ + { + messageId: 'suggestReplaceType', + data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' }, + output: ` +import { IHttpRequestOptions } from 'n8n-workflow'; + +function makeRequest(options: IRequestOptions): Promise { + return this.helpers.request(options); +}`, + }, + ], }, { messageId: 'deprecatedType', data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' }, + suggestions: [ + { + messageId: 'suggestReplaceType', + data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' }, + output: ` +import { IRequestOptions } from 'n8n-workflow'; + +function makeRequest(options: IHttpRequestOptions): Promise { + return this.helpers.request(options); +}`, + }, + ], }, { messageId: 'deprecatedRequestFunction', data: { functionName: 'request', replacement: 'httpRequest' }, + suggestions: [ + { + messageId: 'suggestReplaceFunction', + data: { functionName: 'request', replacement: 'httpRequest' }, + output: ` +import { IRequestOptions } from 'n8n-workflow'; + +function makeRequest(options: IRequestOptions): Promise { + return this.helpers.httpRequest(options); +}`, + }, + ], }, ], }, diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.ts index 97a4f69a2ee..c67deba2c57 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-deprecated-workflow-functions.ts @@ -1,4 +1,7 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import { createRule } from '../utils/index.js'; const DEPRECATED_FUNCTIONS = { request: 'httpRequest', @@ -21,7 +24,8 @@ function isDeprecatedTypeName(name: string): name is keyof typeof DEPRECATED_TYP return name in DEPRECATED_TYPES; } -export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.withoutDocs({ +export const NoDeprecatedWorkflowFunctionsRule = createRule({ + name: 'no-deprecated-workflow-functions', meta: { type: 'problem', docs: { @@ -34,8 +38,11 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without deprecatedType: "'{{ typeName }}' is deprecated. Use '{{ replacement }}' instead.", deprecatedWithoutReplacement: "'{{ functionName }}' is deprecated and should be removed or replaced with alternative implementation.", + suggestReplaceFunction: "Replace '{{ functionName }}' with '{{ replacement }}'", + suggestReplaceType: "Replace '{{ typeName }}' with '{{ replacement }}'", }, schema: [], + hasSuggestions: true, }, defaultOptions: [], create(context) { @@ -45,7 +52,10 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without ImportDeclaration(node) { if (node.source.value === 'n8n-workflow') { node.specifiers.forEach((specifier) => { - if (specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier') { + if ( + specifier.type === AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === AST_NODE_TYPES.Identifier + ) { n8nWorkflowTypes.add(specifier.local.name); } }); @@ -53,7 +63,10 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without }, MemberExpression(node) { - if (node.property.type === 'Identifier' && isDeprecatedFunctionName(node.property.name)) { + if ( + node.property.type === AST_NODE_TYPES.Identifier && + isDeprecatedFunctionName(node.property.name) + ) { if (!isThisHelpersAccess(node)) { return; } @@ -74,6 +87,13 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without replacement, message: getDeprecationMessage(functionName), }, + suggest: [ + { + messageId: 'suggestReplaceFunction', + data: { functionName, replacement }, + fix: (fixer) => fixer.replaceText(node.property, replacement), + }, + ], }); } else { context.report({ @@ -89,7 +109,7 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without TSTypeReference(node) { if ( - node.typeName.type === 'Identifier' && + node.typeName.type === AST_NODE_TYPES.Identifier && isDeprecatedTypeName(node.typeName.name) && n8nWorkflowTypes.has(node.typeName.name) ) { @@ -103,6 +123,13 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without typeName, replacement, }, + suggest: [ + { + messageId: 'suggestReplaceType', + data: { typeName, replacement }, + fix: (fixer) => fixer.replaceText(node.typeName, replacement), + }, + ], }); } }, @@ -111,9 +138,9 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without // Check if this import is from n8n-workflow by looking at the parent ImportDeclaration const importDeclaration = node.parent; if ( - importDeclaration?.type === 'ImportDeclaration' && + importDeclaration?.type === AST_NODE_TYPES.ImportDeclaration && importDeclaration.source.value === 'n8n-workflow' && - node.imported.type === 'Identifier' && + node.imported.type === AST_NODE_TYPES.Identifier && isDeprecatedTypeName(node.imported.name) ) { const typeName = node.imported.name; @@ -126,6 +153,13 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without typeName, replacement, }, + suggest: [ + { + messageId: 'suggestReplaceType', + data: { typeName, replacement }, + fix: (fixer) => fixer.replaceText(node.imported, replacement), + }, + ], }); } }, @@ -137,11 +171,11 @@ export const NoDeprecatedWorkflowFunctionsRule = ESLintUtils.RuleCreator.without * Check if the MemberExpression follows the this.helpers.* pattern */ function isThisHelpersAccess(node: TSESTree.MemberExpression): boolean { - if (node.object?.type === 'MemberExpression') { + if (node.object?.type === AST_NODE_TYPES.MemberExpression) { const outerObject = node.object; return ( - outerObject.object?.type === 'ThisExpression' && - outerObject.property?.type === 'Identifier' && + outerObject.object?.type === AST_NODE_TYPES.ThisExpression && + outerObject.property?.type === AST_NODE_TYPES.Identifier && outerObject.property.name === 'helpers' ); } diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.test.ts index bf921424523..660921bce93 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { NoRestrictedGlobalsRule } from './no-restricted-globals.js'; const ruleTester = new RuleTester(); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.ts index b8d6aaa0ca5..c94e73e5b9b 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-globals.ts @@ -1,6 +1,8 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESTree } from '@typescript-eslint/types'; import type { TSESLint } from '@typescript-eslint/utils'; +import { createRule } from '../utils/index.js'; + const restrictedGlobals = [ 'clearInterval', 'clearTimeout', @@ -15,7 +17,8 @@ const restrictedGlobals = [ '__filename', ]; -export const NoRestrictedGlobalsRule = ESLintUtils.RuleCreator.withoutDocs({ +export const NoRestrictedGlobalsRule = createRule({ + name: 'no-restricted-globals', meta: { type: 'problem', docs: { @@ -33,7 +36,7 @@ export const NoRestrictedGlobalsRule = ESLintUtils.RuleCreator.withoutDocs({ // Skip property access (like console.process - we want process.exit but not obj.process) if ( - parent?.type === 'MemberExpression' && + parent?.type === TSESTree.AST_NODE_TYPES.MemberExpression && parent.property === ref.identifier && !parent.computed ) { diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.test.ts index 5e69450cf93..48db96b9a0c 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { NoRestrictedImportsRule } from './no-restricted-imports.js'; const ruleTester = new RuleTester(); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.ts index c30c2644358..8362b70a14b 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-restricted-imports.ts @@ -1,5 +1,9 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; -import { getModulePath, isDirectRequireCall, isRequireMemberCall } from '../utils/index.js'; +import { + getModulePath, + isDirectRequireCall, + isRequireMemberCall, + createRule, +} from '../utils/index.js'; const allowedModules = [ 'n8n-workflow', @@ -22,7 +26,8 @@ const isModuleAllowed = (modulePath: string): boolean => { return allowedModules.includes(moduleName); }; -export const NoRestrictedImportsRule = ESLintUtils.RuleCreator.withoutDocs({ +export const NoRestrictedImportsRule = createRule({ + name: 'no-restricted-imports', meta: { type: 'problem', docs: { diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts index d4d32b78557..3061f630390 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { NodeUsableAsToolRule } from './node-usable-as-tool.js'; const ruleTester = new RuleTester(); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts index 407452de6a2..2372c076c2b 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/node-usable-as-tool.ts @@ -1,12 +1,14 @@ -import { ESLintUtils } from '@typescript-eslint/utils'; +import { TSESTree } from '@typescript-eslint/types'; + import { isNodeTypeClass, findClassProperty, findObjectProperty, - getBooleanLiteralValue, + createRule, } from '../utils/index.js'; -export const NodeUsableAsToolRule = ESLintUtils.RuleCreator.withoutDocs({ +export const NodeUsableAsToolRule = createRule({ + name: 'node-usable-as-tool', meta: { type: 'problem', docs: { @@ -33,7 +35,7 @@ export const NodeUsableAsToolRule = ESLintUtils.RuleCreator.withoutDocs({ } const descriptionValue = descriptionProperty.value; - if (descriptionValue?.type !== 'ObjectExpression') { + if (descriptionValue?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) { return; } @@ -44,10 +46,10 @@ export const NodeUsableAsToolRule = ESLintUtils.RuleCreator.withoutDocs({ node, messageId: 'missingUsableAsTool', fix(fixer) { - if (descriptionValue?.type === 'ObjectExpression') { + if (descriptionValue?.type === TSESTree.AST_NODE_TYPES.ObjectExpression) { const properties = descriptionValue.properties; if (properties.length === 0) { - const openBrace = descriptionValue.range![0] + 1; + const openBrace = descriptionValue.range[0] + 1; return fixer.insertTextAfterRange( [openBrace, openBrace], '\n\t\tusableAsTool: true,', diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.test.ts index 738e3dc4364..503523693ee 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { PackageNameConventionRule } from './package-name-convention.js'; const ruleTester = new RuleTester(); @@ -80,33 +81,109 @@ ruleTester.run('package-name-convention', PackageNameConventionRule, { name: 'invalid package name - generic', filename: 'package.json', code: '{ "name": "my-package", "version": "1.0.0" }', - errors: [{ messageId: 'invalidPackageName', data: { packageName: 'my-package' } }], + errors: [ + { + messageId: 'invalidPackageName', + data: { packageName: 'my-package' }, + suggestions: [ + { + messageId: 'renameTo', + data: { suggestedName: 'n8n-nodes-my-package' }, + output: '{ "name": "n8n-nodes-my-package", "version": "1.0.0" }', + }, + ], + }, + ], }, { name: 'invalid package name - missing nodes', filename: 'package.json', code: '{ "name": "n8n-example", "version": "1.0.0" }', - errors: [{ messageId: 'invalidPackageName', data: { packageName: 'n8n-example' } }], + errors: [ + { + messageId: 'invalidPackageName', + data: { packageName: 'n8n-example' }, + suggestions: [ + { + messageId: 'renameTo', + data: { suggestedName: 'n8n-nodes-example' }, + output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }', + }, + ], + }, + ], }, { name: 'invalid scoped package name', filename: 'package.json', code: '{ "name": "@company/example-nodes", "version": "1.0.0" }', errors: [ - { messageId: 'invalidPackageName', data: { packageName: '@company/example-nodes' } }, + { + messageId: 'invalidPackageName', + data: { packageName: '@company/example-nodes' }, + suggestions: [ + { + messageId: 'renameTo', + data: { suggestedName: '@company/n8n-nodes-example' }, + output: '{ "name": "@company/n8n-nodes-example", "version": "1.0.0" }', + }, + ], + }, ], }, { name: 'invalid package name - wrong order', filename: 'package.json', code: '{ "name": "nodes-n8n-example", "version": "1.0.0" }', - errors: [{ messageId: 'invalidPackageName', data: { packageName: 'nodes-n8n-example' } }], + errors: [ + { + messageId: 'invalidPackageName', + data: { packageName: 'nodes-n8n-example' }, + suggestions: [ + { + messageId: 'renameTo', + data: { suggestedName: 'n8n-nodes-example' }, + output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }', + }, + ], + }, + ], }, { name: 'empty package name', filename: 'package.json', code: '{ "name": "", "version": "1.0.0" }', - errors: [{ messageId: 'invalidPackageName', data: { packageName: '' } }], + errors: [ + { + messageId: 'invalidPackageName', + data: { packageName: '' }, + suggestions: [], + }, + ], + }, + { + name: 'incomplete package name with missing suffix', + filename: 'package.json', + code: '{ "name": "n8n-nodes-", "version": "1.0.0" }', + errors: [ + { + messageId: 'invalidPackageName', + data: { packageName: 'n8n-nodes-' }, + suggestions: [], + }, + ], + }, + { + name: 'incomplete scoped package name with missing suffix', + filename: 'package.json', + code: '{ "name": "@company/n8n-nodes-", "version": "1.0.0" }', + errors: [ + { + messageId: 'invalidPackageName', + data: { packageName: '@company/n8n-nodes-' }, + suggestions: [], + }, + ], }, ], }); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.ts index cc69bf27d96..4c1803635d2 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/package-name-convention.ts @@ -1,16 +1,23 @@ -import { ESLintUtils, TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint'; -export const PackageNameConventionRule = ESLintUtils.RuleCreator.withoutDocs({ +import { createRule } from '../utils/index.js'; + +export const PackageNameConventionRule = createRule({ + name: 'package-name-convention', meta: { type: 'problem', docs: { description: 'Enforce correct package naming convention for n8n community nodes', }, messages: { + renameTo: "Rename to '{{suggestedName}}'", invalidPackageName: 'Package name "{{ packageName }}" must follow the convention "n8n-nodes-[PACKAGE-NAME]" or "@[AUTHOR]/n8n-nodes-[PACKAGE-NAME]"', }, schema: [], + hasSuggestions: true, }, defaultOptions: [], create(context) { @@ -43,12 +50,29 @@ export const PackageNameConventionRule = ESLintUtils.RuleCreator.withoutDocs({ const packageNameStr = typeof packageName === 'string' ? packageName : null; if (!packageNameStr || !isValidPackageName(packageNameStr)) { + const suggestions: ReportSuggestionArray<'invalidPackageName' | 'renameTo'> = []; + + // Generate package name suggestions if we have a valid string + if (packageNameStr) { + const suggestedNames = generatePackageNameSuggestions(packageNameStr); + for (const suggestedName of suggestedNames) { + suggestions.push({ + messageId: 'renameTo', + data: { suggestedName }, + fix(fixer) { + return fixer.replaceText(nameProperty.value, `"${suggestedName}"`); + }, + }); + } + } + context.report({ node: nameProperty, messageId: 'invalidPackageName', data: { packageName: packageNameStr ?? 'undefined', }, + suggest: suggestions, }); } }, @@ -61,3 +85,23 @@ function isValidPackageName(name: string): boolean { const scoped = /^@.+\/n8n-nodes-.+$/; return unscoped.test(name) || scoped.test(name); } + +function generatePackageNameSuggestions(invalidName: string): string[] { + const cleanName = (name: string) => { + return name + .replace(/^nodes?-?n8n-?/, '') + .replace(/^n8n-/, '') + .replace(/^nodes?-?/, '') + .replace(/^node-/, '') + .replace(/-nodes$/, ''); + }; + + if (invalidName.startsWith('@')) { + const [scope, packagePart] = invalidName.split('/'); + const clean = cleanName(packagePart ?? ''); + return clean ? [`${scope}/n8n-nodes-${clean}`] : []; + } + + const clean = cleanName(invalidName); + return clean ? [`n8n-nodes-${clean}`] : []; +} diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.test.ts index 58dbb841167..db29194820e 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.test.ts @@ -1,4 +1,5 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; + import { ResourceOperationPatternRule } from './resource-operation-pattern.js'; const ruleTester = new RuleTester(); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.ts index f2e4a69f80c..2efc86c22a8 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/resource-operation-pattern.ts @@ -1,13 +1,17 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + import { isNodeTypeClass, findClassProperty, findObjectProperty, getStringLiteralValue, isFileType, + createRule, } from '../utils/index.js'; -export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs({ +export const ResourceOperationPatternRule = createRule({ + name: 'resource-operation-pattern', meta: { type: 'problem', docs: { @@ -26,12 +30,15 @@ export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs( } const analyzeNodeDescription = (descriptionValue: TSESTree.Expression | null): void => { - if (!descriptionValue || descriptionValue.type !== 'ObjectExpression') { + if (!descriptionValue || descriptionValue.type !== AST_NODE_TYPES.ObjectExpression) { return; } const propertiesProperty = findObjectProperty(descriptionValue, 'properties'); - if (!propertiesProperty?.value || propertiesProperty.value.type !== 'ArrayExpression') { + if ( + !propertiesProperty?.value || + propertiesProperty.value.type !== AST_NODE_TYPES.ArrayExpression + ) { return; } @@ -41,7 +48,7 @@ export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs( let operationNode: TSESTree.Node | null = null; for (const property of propertiesArray.elements) { - if (!property || property.type !== 'ObjectExpression') { + if (!property || property.type !== AST_NODE_TYPES.ObjectExpression) { continue; } @@ -62,7 +69,7 @@ export const ResourceOperationPatternRule = ESLintUtils.RuleCreator.withoutDocs( if (name === 'operation' && type === 'options') { operationNode = property; const optionsProperty = findObjectProperty(property, 'options'); - if (optionsProperty?.value?.type === 'ArrayExpression') { + if (optionsProperty?.value?.type === AST_NODE_TYPES.ArrayExpression) { operationCount = optionsProperty.value.elements.length; } } diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts b/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts index 787f2c3b5f4..0f74fdaea2c 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/utils/ast-utils.ts @@ -1,11 +1,13 @@ -import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { distance } from 'fastest-levenshtein'; function implementsInterface(node: TSESTree.ClassDeclaration, interfaceName: string): boolean { return ( node.implements?.some( (impl) => - impl.type === 'TSClassImplements' && - impl.expression.type === 'Identifier' && + impl.type === AST_NODE_TYPES.TSClassImplements && + impl.expression.type === AST_NODE_TYPES.Identifier && impl.expression.name === interfaceName, ) ?? false ); @@ -16,7 +18,7 @@ export function isNodeTypeClass(node: TSESTree.ClassDeclaration): boolean { return true; } - if (node.superClass?.type === 'Identifier' && node.superClass.name === 'Node') { + if (node.superClass?.type === AST_NODE_TYPES.Identifier && node.superClass.name === 'Node') { return true; } @@ -33,11 +35,11 @@ export function findClassProperty( ): TSESTree.PropertyDefinition | null { const property = node.body.body.find( (member) => - member.type === 'PropertyDefinition' && - member.key?.type === 'Identifier' && + member.type === AST_NODE_TYPES.PropertyDefinition && + member.key?.type === AST_NODE_TYPES.Identifier && member.key.name === propertyName, ); - return property?.type === 'PropertyDefinition' ? property : null; + return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null; } export function findObjectProperty( @@ -46,13 +48,15 @@ export function findObjectProperty( ): TSESTree.Property | null { const property = obj.properties.find( (prop) => - prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === propertyName, + prop.type === AST_NODE_TYPES.Property && + prop.key.type === AST_NODE_TYPES.Identifier && + prop.key.name === propertyName, ); - return property?.type === 'Property' ? property : null; + return property?.type === AST_NODE_TYPES.Property ? property : null; } export function getLiteralValue(node: TSESTree.Node | null): string | boolean | number | null { - if (node?.type === 'Literal') { + if (node?.type === AST_NODE_TYPES.Literal) { return node.value as string | boolean | number | null; } return null; @@ -70,7 +74,7 @@ export function getModulePath(node: TSESTree.Node | null): string | null { } if ( - node?.type === 'TemplateLiteral' && + node?.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0 && node.quasis.length === 1 ) { @@ -90,7 +94,7 @@ export function findArrayLiteralProperty( propertyName: string, ): TSESTree.ArrayExpression | null { const property = findObjectProperty(obj, propertyName); - if (property?.value.type === 'ArrayExpression') { + if (property?.value.type === AST_NODE_TYPES.ArrayExpression) { return property.value; } return null; @@ -100,11 +104,11 @@ export function hasArrayLiteralValue( node: TSESTree.PropertyDefinition, searchValue: string, ): boolean { - if (node.value?.type !== 'ArrayExpression') return false; + if (node.value?.type !== AST_NODE_TYPES.ArrayExpression) return false; return node.value.elements.some( (element) => - element?.type === 'Literal' && + element?.type === AST_NODE_TYPES.Literal && typeof element.value === 'string' && element.value === searchValue, ); @@ -125,14 +129,16 @@ export function isFileType(filename: string, extension: string): boolean { export function isDirectRequireCall(node: TSESTree.CallExpression): boolean { return ( - node.callee.type === 'Identifier' && node.callee.name === 'require' && node.arguments.length > 0 + node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name === 'require' && + node.arguments.length > 0 ); } export function isRequireMemberCall(node: TSESTree.CallExpression): boolean { return ( - node.callee.type === 'MemberExpression' && - node.callee.object.type === 'Identifier' && + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && node.callee.object.name === 'require' && node.arguments.length > 0 ); @@ -148,7 +154,7 @@ export function extractCredentialInfoFromArray( return { name: stringValue, node: element }; } - if (element.type === 'ObjectExpression') { + if (element.type === AST_NODE_TYPES.ObjectExpression) { const nameProperty = findObjectProperty(element, 'name'); const testedByProperty = findObjectProperty(element, 'testedBy'); @@ -161,7 +167,7 @@ export function extractCredentialInfoFromArray( if (nameValue) { return { name: nameValue, - testedBy: testedByValue || undefined, + testedBy: testedByValue ?? undefined, node: nameProperty.value, }; } @@ -177,3 +183,25 @@ export function extractCredentialNameFromArray( const info = extractCredentialInfoFromArray(element); return info ? { name: info.name, node: info.node } : null; } + +export function findSimilarStrings( + target: string, + candidates: Set, + maxDistance: number = 3, + maxResults: number = 3, +): string[] { + const matches: Array<{ name: string; distance: number }> = []; + + for (const candidate of candidates) { + const levenshteinDistance = distance(target.toLowerCase(), candidate.toLowerCase()); + + if (levenshteinDistance <= maxDistance) { + matches.push({ name: candidate, distance: levenshteinDistance }); + } + } + + return matches + .sort((a, b) => a.distance - b.distance) + .slice(0, maxResults) + .map((match) => match.name); +} diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/utils/file-utils.ts b/packages/@n8n/eslint-plugin-community-nodes/src/utils/file-utils.ts index 2c62828bedb..f855db9d704 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/utils/file-utils.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/utils/file-utils.ts @@ -1,7 +1,9 @@ -import { readFileSync, existsSync } from 'node:fs'; +import type { TSESTree } from '@typescript-eslint/typescript-estree'; +import { parse, simpleTraverse, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import * as path from 'node:path'; import { dirname, parse as parsePath } from 'node:path'; -import { parse, simpleTraverse, TSESTree } from '@typescript-eslint/typescript-estree'; + import { isCredentialTypeClass, isNodeTypeClass, @@ -9,6 +11,7 @@ import { getStringLiteralValue, findArrayLiteralProperty, extractCredentialInfoFromArray, + findSimilarStrings, } from './ast-utils.js'; /** @@ -46,11 +49,11 @@ export function safeJoinPath(parentPath: string, ...paths: string[]): string { } export function findPackageJson(startPath: string): string | null { - let currentDir = startPath; + let currentDir = path.dirname(startPath); while (parsePath(currentDir).dir !== parsePath(currentDir).root) { const testPath = safeJoinPath(currentDir, 'package.json'); - if (existsSync(testPath)) { + if (fileExistsWithCaseSync(testPath)) { return testPath; } @@ -60,10 +63,24 @@ export function findPackageJson(startPath: string): string | null { return null; } -function readPackageJsonN8n(packageJsonPath: string): any { +interface PackageJsonN8n { + credentials?: string[]; + nodes?: string[]; + [key: string]: unknown; +} + +function isValidPackageJson(obj: unknown): obj is { n8n?: PackageJsonN8n } { + return typeof obj === 'object' && obj !== null; +} + +function readPackageJsonN8n(packageJsonPath: string): PackageJsonN8n { try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - return packageJson.n8n || {}; + const content = readFileSync(packageJsonPath, 'utf8'); + const parsed: unknown = JSON.parse(content); + if (isValidPackageJson(parsed)) { + return parsed.n8n ?? {}; + } + return {}; } catch { return {}; } @@ -87,7 +104,7 @@ function resolveN8nFilePaths(packageJsonPath: string, filePaths: string[]): stri export function readPackageJsonCredentials(packageJsonPath: string): Set { const n8nConfig = readPackageJsonN8n(packageJsonPath); - const credentialPaths = n8nConfig.credentials || []; + const credentialPaths = n8nConfig.credentials ?? []; const credentialFiles = resolveN8nFilePaths(packageJsonPath, credentialPaths); const credentialNames: string[] = []; @@ -117,7 +134,7 @@ export function extractCredentialNameFromFile(credentialFilePath: string): strin simpleTraverse(ast, { enter(node: TSESTree.Node) { - if (node.type === 'ClassDeclaration' && isCredentialTypeClass(node)) { + if (node.type === AST_NODE_TYPES.ClassDeclaration && isCredentialTypeClass(node)) { const nameProperty = findClassProperty(node, 'name'); if (nameProperty) { const nameValue = getStringLiteralValue(nameProperty.value); @@ -147,8 +164,9 @@ export function validateIconPath( const isFile = iconPath.startsWith('file:'); const relativePath = iconPath.replace(/^file:/, ''); const isSvg = relativePath.endsWith('.svg'); - const fullPath = safeJoinPath(baseDir, relativePath); - const exists = existsSync(fullPath); + // Should not use safeJoinPath here because iconPath can be outside of the node class folder + const fullPath = path.join(baseDir, relativePath); + const exists = fileExistsWithCaseSync(fullPath); return { isValid: isFile && isSvg && exists, @@ -160,7 +178,7 @@ export function validateIconPath( export function readPackageJsonNodes(packageJsonPath: string): string[] { const n8nConfig = readPackageJsonN8n(packageJsonPath); - const nodePaths = n8nConfig.nodes || []; + const nodePaths = n8nConfig.nodes ?? []; return resolveN8nFilePaths(packageJsonPath, nodePaths); } @@ -202,11 +220,11 @@ function checkCredentialUsageInFile( simpleTraverse(ast, { enter(node: TSESTree.Node) { - if (node.type === 'ClassDeclaration' && isNodeTypeClass(node)) { + if (node.type === AST_NODE_TYPES.ClassDeclaration && isNodeTypeClass(node)) { const descriptionProperty = findClassProperty(node, 'description'); if ( !descriptionProperty?.value || - descriptionProperty.value.type !== 'ObjectExpression' + descriptionProperty.value.type !== AST_NODE_TYPES.ObjectExpression ) { return; } @@ -237,3 +255,40 @@ function checkCredentialUsageInFile( return { hasUsage: false, allTestedBy: true }; } } + +function fileExistsWithCaseSync(filePath: string): boolean { + try { + const dir = path.dirname(filePath); + const file = path.basename(filePath); + const files = new Set(readdirSync(dir)); + + return files.has(file); + } catch { + return false; + } +} + +export function findSimilarSvgFiles(targetPath: string, baseDir: string): string[] { + try { + const targetFileName = path.basename(targetPath, path.extname(targetPath)); + const targetDir = path.dirname(targetPath); + // Should not use safeJoinPath here because iconPath can be outside of the node class folder + const searchDir = path.join(baseDir, targetDir); + + if (!existsSync(searchDir)) { + return []; + } + + const files = readdirSync(searchDir); + const svgFileNames = files + .filter((file) => file.endsWith('.svg')) + .map((file) => path.basename(file, '.svg')); + + const candidateNames = new Set(svgFileNames); + const similarNames = findSimilarStrings(targetFileName, candidateNames); + + return similarNames.map((name) => path.join(targetDir, `${name}.svg`)); + } catch { + return []; + } +} diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/utils/index.ts b/packages/@n8n/eslint-plugin-community-nodes/src/utils/index.ts index a5e12c21031..622daee5834 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/utils/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './ast-utils.js'; export * from './file-utils.js'; +export * from './rule-creator.js'; diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/utils/rule-creator.ts b/packages/@n8n/eslint-plugin-community-nodes/src/utils/rule-creator.ts new file mode 100644 index 00000000000..bcb32283604 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/utils/rule-creator.ts @@ -0,0 +1,6 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +const REPO_URL = 'https://github.com/n8n-io/n8n'; +const DOCS_PATH = 'blob/master/packages/@n8n/eslint-plugin-community-nodes/docs/rules'; + +export const createRule = ESLintUtils.RuleCreator((name) => `${REPO_URL}/${DOCS_PATH}/${name}.md`); diff --git a/packages/@n8n/eslint-plugin-community-nodes/tsconfig.build.json b/packages/@n8n/eslint-plugin-community-nodes/tsconfig.build.json new file mode 100644 index 00000000000..9b53dc92ce8 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/@n8n/eslint-plugin-community-nodes/tsconfig.eslint.json b/packages/@n8n/eslint-plugin-community-nodes/tsconfig.eslint.json new file mode 100644 index 00000000000..9981ca25309 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/packages/@n8n/eslint-plugin-community-nodes/tsconfig.json b/packages/@n8n/eslint-plugin-community-nodes/tsconfig.json index b0daf732049..6fb7a018ff2 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/tsconfig.json +++ b/packages/@n8n/eslint-plugin-community-nodes/tsconfig.json @@ -6,6 +6,5 @@ "outDir": "dist", "types": ["vitest/globals"] }, - "include": ["src/**/*.ts"], - "exclude": ["**/*.test.ts"] + "include": ["src/**/*.ts"] } diff --git a/packages/@n8n/extension-sdk/package.json b/packages/@n8n/extension-sdk/package.json index 43c9a688384..133eb70ead5 100644 --- a/packages/@n8n/extension-sdk/package.json +++ b/packages/@n8n/extension-sdk/package.json @@ -31,11 +31,11 @@ }, "scripts": { "clean": "rimraf dist", - "dev": "tsup --watch", + "dev": "tsdown --watch", "lint": "eslint . --quiet", "typecheck:frontend": "vue-tsc --noEmit --project tsconfig.frontend.json", "typecheck:backend": "tsc --noEmit --project tsconfig.backend.json", - "build": "pnpm \"/^typecheck:.+/\" && pnpm clean && tsup && pnpm create-json-schema", + "build": "pnpm \"/^typecheck:.+/\" && pnpm clean && tsdown && pnpm create-json-schema", "create-json-schema": "tsx scripts/create-json-schema.ts", "preview": "vite preview" }, @@ -47,6 +47,7 @@ "@n8n/typescript-config": "workspace:*", "@vitejs/plugin-vue": "catalog:frontend", "@vue/tsconfig": "catalog:frontend", + "tsdown": "catalog:", "rimraf": "catalog:", "vite": "catalog:", "vue": "catalog:frontend", diff --git a/packages/@n8n/extension-sdk/tsconfig.backend.json b/packages/@n8n/extension-sdk/tsconfig.backend.json index 1258e5fd844..0fb2a1a200c 100644 --- a/packages/@n8n/extension-sdk/tsconfig.backend.json +++ b/packages/@n8n/extension-sdk/tsconfig.backend.json @@ -1,6 +1,8 @@ { "extends": "@n8n/typescript-config/tsconfig.common.json", "compilerOptions": { + "composite": true, + "declaration": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.backend.tsbuildinfo" }, "include": ["src/backend/**/*.ts"] diff --git a/packages/@n8n/extension-sdk/tsconfig.common.json b/packages/@n8n/extension-sdk/tsconfig.common.json index 1cad3f9425e..c96e32876d4 100644 --- a/packages/@n8n/extension-sdk/tsconfig.common.json +++ b/packages/@n8n/extension-sdk/tsconfig.common.json @@ -1,6 +1,8 @@ { "extends": "@n8n/typescript-config/tsconfig.common.json", "compilerOptions": { + "composite": true, + "declaration": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.backend.tsbuildinfo" }, "include": ["src/*.ts"] diff --git a/packages/@n8n/extension-sdk/tsconfig.frontend.json b/packages/@n8n/extension-sdk/tsconfig.frontend.json index 7c6c3f3addf..b57b4d95f7f 100644 --- a/packages/@n8n/extension-sdk/tsconfig.frontend.json +++ b/packages/@n8n/extension-sdk/tsconfig.frontend.json @@ -1,6 +1,8 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", "compilerOptions": { + "composite": true, + "declaration": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.frontend.tsbuildinfo" }, "include": ["src/frontend/**/*.ts", "src/frontend/**/*.vue"] diff --git a/packages/@n8n/extension-sdk/tsup.config.ts b/packages/@n8n/extension-sdk/tsdown.config.ts similarity index 89% rename from packages/@n8n/extension-sdk/tsup.config.ts rename to packages/@n8n/extension-sdk/tsdown.config.ts index e8c74c94083..d5289487546 100644 --- a/packages/@n8n/extension-sdk/tsup.config.ts +++ b/packages/@n8n/extension-sdk/tsdown.config.ts @@ -1,5 +1,6 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; +// eslint-disable-next-line import-x/no-default-export export default defineConfig([ { clean: false, diff --git a/packages/@n8n/node-cli/README.md b/packages/@n8n/node-cli/README.md index e8f62e3216f..7c50ce7b7e5 100644 --- a/packages/@n8n/node-cli/README.md +++ b/packages/@n8n/node-cli/README.md @@ -148,6 +148,23 @@ n8n-node lint n8n-node lint --fix ``` +#### `n8n-node cloud-support` + +Manage n8n Cloud eligibility. + +```bash +n8n-node cloud-support [enable|disable] +``` + +**Arguments:** +| Argument | Description | +|----------|-------------| +| _(none)_ | Show current cloud support status | +| `enable` | Enable strict mode + default ESLint config | +| `disable` | Allow custom ESLint config (disables cloud eligibility) | + +Strict mode enforces the default ESLint configuration and community node rules required for n8n Cloud verification. When disabled, you can customize your ESLint config but your node won't be eligible for n8n Cloud verification. + #### `n8n-node release` Publish your community node package to npm. diff --git a/packages/@n8n/node-cli/eslint.config.mjs b/packages/@n8n/node-cli/eslint.config.mjs index 7a8559b6d79..98d35ba69c1 100644 --- a/packages/@n8n/node-cli/eslint.config.mjs +++ b/packages/@n8n/node-cli/eslint.config.mjs @@ -5,9 +5,9 @@ export default defineConfig( globalIgnores(['src/template/templates/**/template', 'src/template/templates/shared']), nodeConfig, { - ignores: ['**/*.test.ts'], + files: ['**/*.test.ts', 'src/test-utils/**/*'], rules: { - 'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }], + 'import-x/no-extraneous-dependencies': ['error', { devDependencies: true }], }, }, { diff --git a/packages/@n8n/node-cli/package.json b/packages/@n8n/node-cli/package.json index 9203a47662e..e9385954905 100644 --- a/packages/@n8n/node-cli/package.json +++ b/packages/@n8n/node-cli/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/node-cli", - "version": "0.11.0", + "version": "0.13.0", "description": "Official CLI for developing community nodes for n8n", "bin": { "n8n-node": "bin/n8n-node.mjs" @@ -45,12 +45,12 @@ }, "dependencies": { "@clack/prompts": "^0.11.0", + "@n8n/eslint-plugin-community-nodes": "workspace:*", "@oclif/core": "^4.5.2", "change-case": "^5.4.4", "eslint-import-resolver-typescript": "^4.4.3", "eslint-plugin-import-x": "^4.15.2", "eslint-plugin-n8n-nodes-base": "1.16.3", - "@n8n/eslint-plugin-community-nodes": "workspace:*", "fast-glob": "catalog:", "handlebars": "4.7.8", "picocolors": "catalog:", @@ -67,6 +67,7 @@ "@oclif/test": "^4.1.13", "eslint": "catalog:", "typescript": "catalog:", + "vitest": "catalog:", "vitest-mock-extended": "catalog:" }, "peerDependencies": { diff --git a/packages/@n8n/node-cli/src/commands/build.test.ts b/packages/@n8n/node-cli/src/commands/build.test.ts new file mode 100644 index 00000000000..23458ebf843 --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/build.test.ts @@ -0,0 +1,134 @@ +import { cancel, outro } from '@clack/prompts'; +import fs from 'node:fs/promises'; + +import { CommandTester } from '../test-utils/command-tester'; +import { mockSpawn } from '../test-utils/mock-child-process'; +import { setupTestPackage } from '../test-utils/package-setup'; +import { tmpdirTest } from '../test-utils/temp-fs'; + +describe('build command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + tmpdirTest( + 'successful build - compiles TypeScript and copies static files', + async ({ tmpdir }) => { + await setupTestPackage(tmpdir); + await fs.mkdir(`${tmpdir}/src/icons`, { recursive: true }); + await fs.mkdir(`${tmpdir}/src/assets`, { recursive: true }); + await fs.mkdir(`${tmpdir}/src/__schema__`, { recursive: true }); + await fs.writeFile(`${tmpdir}/src/icons/icon.png`, 'fake-png-content'); + await fs.writeFile(`${tmpdir}/src/assets/logo.svg`, 'fake-svg'); + await fs.writeFile(`${tmpdir}/src/__schema__/node.json`, '{"fake": "schema"}'); + + mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 }); + + await CommandTester.run('build'); + + await expect(tmpdir).toHaveFileEqual('dist/src/icons/icon.png', 'fake-png-content'); + await expect(tmpdir).toHaveFileEqual('dist/src/assets/logo.svg', 'fake-svg'); + await expect(tmpdir).toHaveFileEqual('dist/src/__schema__/node.json', '{"fake": "schema"}'); + + expect(tmpdir).toHaveFile('dist/src/icons'); + expect(tmpdir).toHaveFile('dist/src/assets'); + expect(tmpdir).toHaveFile('dist/src/__schema__'); + + expect(outro).toHaveBeenCalledWith('✓ Build successful'); + }, + ); + + tmpdirTest('TypeScript compilation failure - exits with error', async ({ tmpdir }) => { + await setupTestPackage(tmpdir); + mockSpawn('pnpm', ['exec', '--', 'tsc'], { + exitCode: 1, + stderr: "error TS2304: Cannot find name 'unknown_var'.", + }); + + await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1'); + + expect(cancel).toHaveBeenCalledWith('TypeScript build failed'); + }); + + tmpdirTest('child process error - handles spawn errors', async ({ tmpdir }) => { + await setupTestPackage(tmpdir); + + mockSpawn('pnpm', ['exec', '--', 'tsc'], { + error: 'ENOENT: no such file or directory, spawn tsc', + }); + + await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1'); + + expect(cancel).toHaveBeenCalledWith('TypeScript build failed'); + }); + + tmpdirTest('invalid package - not an n8n node package', async ({ tmpdir }) => { + await fs.writeFile( + `${tmpdir}/package.json`, + JSON.stringify({ + name: 'regular-package', + version: '1.0.0', + // No n8n field - this makes it an invalid n8n package + }), + ); + + await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1'); + + expect(cancel).toHaveBeenCalledWith('n8n-node build can only be run in an n8n node package'); + }); + + tmpdirTest('no static files - still completes successfully', async ({ tmpdir }) => { + await setupTestPackage(tmpdir); + + mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 }); + + await CommandTester.run('build'); + + expect(outro).toHaveBeenCalledWith('✓ Build successful'); + }); + + tmpdirTest('static files in nested directories - creates correct paths', async ({ tmpdir }) => { + await setupTestPackage(tmpdir); + await fs.mkdir(`${tmpdir}/src/nodes/icons`, { recursive: true }); + await fs.mkdir(`${tmpdir}/src/nodes/subdir/__schema__`, { recursive: true }); + await fs.mkdir(`${tmpdir}/src/assets/images`, { recursive: true }); + await fs.writeFile(`${tmpdir}/src/nodes/icons/node1.png`, 'fake-node1-png'); + await fs.writeFile(`${tmpdir}/src/nodes/subdir/__schema__/schema.json`, '{"node": "schema"}'); + await fs.writeFile(`${tmpdir}/src/assets/images/logo.svg`, 'logo'); + + mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 }); + + await CommandTester.run('build'); + + await expect(tmpdir).toHaveFileEqual('dist/src/nodes/icons/node1.png', 'fake-node1-png'); + await expect(tmpdir).toHaveFileEqual( + 'dist/src/nodes/subdir/__schema__/schema.json', + '{"node": "schema"}', + ); + await expect(tmpdir).toHaveFileEqual('dist/src/assets/images/logo.svg', 'logo'); + + expect(tmpdir).toHaveFile('dist/src/nodes/icons'); + expect(tmpdir).toHaveFile('dist/src/nodes/subdir/__schema__'); + expect(tmpdir).toHaveFile('dist/src/assets/images'); + + expect(outro).toHaveBeenCalledWith('✓ Build successful'); + }); + + tmpdirTest('rimraf clears existing dist directory', async ({ tmpdir }) => { + await setupTestPackage(tmpdir); + await fs.mkdir(`${tmpdir}/dist/old-dir`, { recursive: true }); + await fs.writeFile(`${tmpdir}/dist/old-file.js`, 'old content'); + + expect(tmpdir).toHaveFile('dist/old-file.js'); + expect(tmpdir).toHaveFile('dist/old-dir'); + + mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 }); + + await CommandTester.run('build'); + + expect(tmpdir).toNotHaveFile('dist/old-file.js'); + expect(tmpdir).toNotHaveFile('dist/old-dir'); + + expect(outro).toHaveBeenCalledWith('✓ Build successful'); + }); +}); diff --git a/packages/@n8n/node-cli/src/commands/cloud-support.test.ts b/packages/@n8n/node-cli/src/commands/cloud-support.test.ts new file mode 100644 index 00000000000..2ff9a3ed7dc --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/cloud-support.test.ts @@ -0,0 +1,124 @@ +import { CommandTester } from '../test-utils/command-tester'; +import { MockPrompt } from '../test-utils/mock-prompts'; +import { setupTestPackage } from '../test-utils/package-setup'; +import { tmpdirTest } from '../test-utils/temp-fs'; + +describe('cloud-support command', () => { + beforeEach(() => { + MockPrompt.reset(); + }); + + describe('enable', () => { + tmpdirTest('writes correct eslint config and updates package.json', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: "import { config } from '@n8n/node-cli/eslint'; export default config;", + }); + + await CommandTester.run('cloud-support enable'); + + await expect(tmpdir).toHaveFileEqual( + 'eslint.config.mjs', + "import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n", + ); + await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true'); + }); + }); + + describe('status', () => { + tmpdirTest('shows enabled status when strict mode and default config', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: true } }, + eslintConfig: true, + }); + + const result = await CommandTester.run('cloud-support'); + + expect(result).toHaveLoggedSuccess('ENABLED'); + }); + + tmpdirTest('shows disabled status when not strict mode', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: false } }, + eslintConfig: true, + }); + + const result = await CommandTester.run('cloud-support'); + + expect(result).toHaveLoggedWarning('DISABLED'); + }); + }); + + describe('disable', () => { + tmpdirTest('updates config when user confirms', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: true } }, + eslintConfig: true, + }); + + MockPrompt.setup([ + { + question: 'Are you sure you want to disable cloud support?', + answer: true, + }, + ]); + + const result = await CommandTester.run('cloud-support disable'); + + await expect(tmpdir).toHaveFileEqual( + 'eslint.config.mjs', + "import { configWithoutCloudSupport } from '@n8n/node-cli/eslint';\n\nexport default configWithoutCloudSupport;\n", + ); + + await expect(tmpdir).toHaveFileContaining('package.json', '"strict": false'); + + expect(result).toHaveLoggedSuccess( + 'Updated eslint.config.mjs to use configWithoutCloudSupport', + ); + expect(result).toHaveLoggedSuccess('Disabled strict mode in package.json'); + }); + + tmpdirTest('does not update config when user cancels', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: true } }, + eslintConfig: true, + }); + + MockPrompt.setup([ + { + question: 'Are you sure you want to disable cloud support?', + answer: 'CANCEL', + }, + ]); + + await expect(CommandTester.run('cloud-support disable')).rejects.toThrow('EEXIT: 0'); + + await expect(tmpdir).toHaveFileEqual( + 'eslint.config.mjs', + "import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n", + ); + await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true'); + }); + + tmpdirTest('does not update config when user declines', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: true } }, + eslintConfig: true, + }); + + MockPrompt.setup([ + { + question: 'Are you sure you want to disable cloud support?', + answer: false, + }, + ]); + + await expect(CommandTester.run('cloud-support disable')).rejects.toThrow('EEXIT: 0'); + + await expect(tmpdir).toHaveFileEqual( + 'eslint.config.mjs', + "import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n", + ); + await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true'); + }); + }); +}); diff --git a/packages/@n8n/node-cli/src/commands/cloud-support.ts b/packages/@n8n/node-cli/src/commands/cloud-support.ts new file mode 100644 index 00000000000..448ef4e2f71 --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/cloud-support.ts @@ -0,0 +1,168 @@ +import { confirm, intro, log, outro } from '@clack/prompts'; +import { Args, Command } from '@oclif/core'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import picocolors from 'picocolors'; + +import { suggestCloudSupportCommand, suggestLintCommand } from '../utils/command-suggestions'; +import { getPackageJson, updatePackageJson } from '../utils/package'; +import { ensureN8nPackage, onCancel, withCancelHandler } from '../utils/prompts'; + +export default class CloudSupport extends Command { + static override description = 'Enable or disable cloud support for this node'; + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> enable', + '<%= config.bin %> <%= command.id %> disable', + ]; + + static override args = { + action: Args.string({ + description: 'Action to perform (defaults to showing current status)', + required: false, + options: ['enable', 'disable'], + }), + }; + + async run(): Promise { + const { args } = await this.parse(CloudSupport); + + await ensureN8nPackage('cloud-support'); + + const workingDir = process.cwd(); + + if (args.action === 'enable') { + await this.enableCloudSupport(workingDir); + } else if (args.action === 'disable') { + await this.disableCloudSupport(workingDir); + } else { + await this.showCloudSupportStatus(workingDir); + } + } + + private async enableCloudSupport(workingDir: string): Promise { + intro(picocolors.inverse(' n8n-node cloud-support enable ')); + + await this.updateEslintConfig(workingDir, true); + log.success(`Updated ${picocolors.cyan('eslint.config.mjs')} to use default config`); + + await this.updateStrictMode(workingDir, true); + log.success(`Enabled strict mode in ${picocolors.cyan('package.json')}`); + + const lintCommand = await suggestLintCommand(); + outro( + `Cloud support enabled. Run "${lintCommand}" to check compliance - your node must pass linting to be eligible for n8n Cloud publishing.`, + ); + } + + private async disableCloudSupport(workingDir: string): Promise { + intro(picocolors.inverse(' n8n-node cloud-support disable ')); + + log.warning(`This will make your node ineligible for n8n Cloud verification! + +The following changes will be made: + • Switch to ${picocolors.magenta('configWithoutCloudSupport')} in ${picocolors.cyan('eslint.config.mjs')} + • Disable strict mode in ${picocolors.cyan('package.json')}`); + + const confirmed = await withCancelHandler( + confirm({ + message: 'Are you sure you want to disable cloud support?', + initialValue: false, + }), + ); + + if (!confirmed) { + onCancel('Cloud support unchanged'); + return; + } + + // 1. Update eslint.config.mjs + await this.updateEslintConfig(workingDir, false); + log.success( + `Updated ${picocolors.cyan('eslint.config.mjs')} to use ${picocolors.magenta('configWithoutCloudSupport')}`, + ); + + // 2. Disable strict mode in package.json + await this.updateStrictMode(workingDir, false); + log.success(`Disabled strict mode in ${picocolors.cyan('package.json')}`); + + outro( + "Cloud support disabled. Your node may pass linting but it won't pass verification for n8n Cloud.", + ); + } + + private async updateEslintConfig(workingDir: string, enableCloud: boolean): Promise { + const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs'); + const newConfig = enableCloud + ? `import { config } from '@n8n/node-cli/eslint'; + +export default config; +` + : `import { configWithoutCloudSupport } from '@n8n/node-cli/eslint'; + +export default configWithoutCloudSupport; +`; + + await fs.writeFile(eslintConfigPath, newConfig, 'utf-8'); + } + + private async updateStrictMode(workingDir: string, enableStrict: boolean): Promise { + await updatePackageJson(workingDir, (packageJson) => { + packageJson.n8n = packageJson.n8n ?? {}; + packageJson.n8n.strict = enableStrict; + return packageJson; + }); + } + + private async showCloudSupportStatus(workingDir: string): Promise { + intro(picocolors.inverse(' n8n-node cloud-support ')); + + try { + const packageJson = await getPackageJson(workingDir); + const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs'); + + // Check strict mode + const isStrictMode = packageJson?.n8n?.strict === true; + + // Check eslint config + let isUsingDefaultConfig = false; + try { + const eslintConfig = await fs.readFile(eslintConfigPath, 'utf-8'); + const normalizedConfig = eslintConfig.replace(/\s+/g, ' ').trim(); + const expectedDefault = + "import { config } from '@n8n/node-cli/eslint'; export default config;"; + isUsingDefaultConfig = normalizedConfig === expectedDefault; + } catch { + // eslint config doesn't exist or can't be read + } + + const isCloudSupported = isStrictMode && isUsingDefaultConfig; + + if (isCloudSupported) { + log.success(`✅ Cloud support is ${picocolors.green('ENABLED')} + • Strict mode: ${picocolors.green('enabled')} + • ESLint config: ${picocolors.green('using default config')} + • Status: ${picocolors.green('eligible')} for n8n Cloud verification ${picocolors.dim('(if lint passes)')}`); + } else { + log.warning(`⚠️ Cloud support is ${picocolors.yellow('DISABLED')} + • Strict mode: ${isStrictMode ? picocolors.green('enabled') : picocolors.red('disabled')} + • ESLint config: ${isUsingDefaultConfig ? picocolors.green('using default config') : picocolors.red('using custom config')} + • Status: ${picocolors.red('NOT eligible')} for n8n Cloud verification`); + } + + const enableCommand = await suggestCloudSupportCommand('enable'); + const disableCommand = await suggestCloudSupportCommand('disable'); + const lintCommand = await suggestLintCommand(); + + log.info(`Available commands: + • ${enableCommand} - Enable cloud support + • ${disableCommand} - Disable cloud support + • ${lintCommand} - Check compliance for cloud publishing`); + + outro('Use the commands above to change cloud support settings or check compliance'); + } catch (error) { + log.error('Failed to read package.json or determine cloud support status'); + outro('Make sure you are in the root directory of your node package'); + } + } +} diff --git a/packages/@n8n/node-cli/src/commands/dev/index.test.ts b/packages/@n8n/node-cli/src/commands/dev/index.test.ts new file mode 100644 index 00000000000..8603de43154 --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/dev/index.test.ts @@ -0,0 +1,27 @@ +import { intro } from '@clack/prompts'; + +import { CommandTester } from '../../test-utils/command-tester'; +import { mockSpawn } from '../../test-utils/mock-child-process'; +import { setupTestPackage } from '../../test-utils/package-setup'; +import { tmpdirTest } from '../../test-utils/temp-fs'; + +describe('dev command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + tmpdirTest( + 'successful dev setup with external-n8n flag - links node and starts watcher', + async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { name: 'test-custom-node' }, + }); + + mockSpawn('pnpm', ['link'], { exitCode: 0 }); + + await expect(CommandTester.run('dev --external-n8n')).rejects.toThrow('EEXIT: 0'); + + expect(intro).toHaveBeenCalledWith(expect.stringContaining('n8n-node dev')); + }, + ); +}); diff --git a/packages/@n8n/node-cli/src/commands/dev/utils.ts b/packages/@n8n/node-cli/src/commands/dev/utils.ts index 26537038fe2..2af4d85c8d1 100644 --- a/packages/@n8n/node-cli/src/commands/dev/utils.ts +++ b/packages/@n8n/node-cli/src/commands/dev/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable no-control-regex */ -import { type ChildProcess, spawn } from 'child_process'; +import { type ChildProcess, spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import type { Formatter } from 'picocolors/types'; diff --git a/packages/@n8n/node-cli/src/commands/lint.test.ts b/packages/@n8n/node-cli/src/commands/lint.test.ts new file mode 100644 index 00000000000..96ed57411ee --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/lint.test.ts @@ -0,0 +1,200 @@ +import { cancel } from '@clack/prompts'; +import fs from 'node:fs/promises'; + +import { CommandTester } from '../test-utils/command-tester'; +import { stripAnsiCodes } from '../test-utils/matchers'; +import { mockSpawn } from '../test-utils/mock-child-process'; +import { setupTestPackage } from '../test-utils/package-setup'; +import { tmpdirTest } from '../test-utils/temp-fs'; + +describe('lint command', () => { + const mockProcessStdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + tmpdirTest('successful lint - runs eslint with correct arguments', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: true, + }); + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { exitCode: 0 }); + + const result = await CommandTester.run('lint'); + + expect(result).toBeDefined(); + }); + + tmpdirTest('successful lint with warnings - shows warnings in output', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: true, + }); + + const eslintWarnings = ` +/tmp/project/src/index.ts + 10:5 warning Unused variable 'unusedVar' @typescript-eslint/no-unused-vars + 15:3 warning Missing return type @typescript-eslint/explicit-function-return-type + +✖ 2 problems (0 errors, 2 warnings) +`; + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { + exitCode: 0, + stdout: eslintWarnings, + }); + + const result = await CommandTester.run('lint'); + + expect(result).toBeDefined(); + + const stdoutCalls = mockProcessStdout.mock.calls.flat(); + const allOutput = stripAnsiCodes( + stdoutCalls.map((call) => (Buffer.isBuffer(call) ? call.toString() : String(call))).join(''), + ); + + expect(allOutput).toContain('Unused variable'); + }); + + tmpdirTest('lint with fix flag - passes --fix to eslint', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: true, + }); + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.', '--fix'], { exitCode: 0 }); + + const result = await CommandTester.run('lint --fix'); + + expect(result).toBeDefined(); + }); + + tmpdirTest('eslint failure - exits with error code', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: true, + }); + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { + exitCode: 1, + stderr: 'ESLint found 3 errors', + }); + + await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1'); + }); + + tmpdirTest('eslint spawn error - handles process errors', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: true, + }); + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { + error: 'ENOENT: no such file or directory, spawn eslint', + }); + + await expect(CommandTester.run('lint')).rejects.toThrow(); + }); + + tmpdirTest('invalid package - not an n8n node package', async ({ tmpdir }) => { + await fs.writeFile( + `${tmpdir}/package.json`, + JSON.stringify({ + name: 'regular-package', + version: '1.0.0', + // No n8n field - this makes it an invalid n8n package + }), + ); + + await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1'); + + expect(cancel).toHaveBeenCalledWith('lint can only be run in an n8n node package'); + }); + + tmpdirTest('strict mode with default config - passes validation', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: true } }, + eslintConfig: true, + }); + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { exitCode: 0 }); + + const result = await CommandTester.run('lint'); + + expect(result).toBeDefined(); + }); + + tmpdirTest('cloud-only lint errors - suggests disabling cloud support', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: true, + }); + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { + exitCode: 1, + stderr: 'Error: @n8n/eslint-plugin-community-nodes/no-restricted-globals rule failed', + }); + + await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1'); + + const stdoutCalls = mockProcessStdout.mock.calls.flat(); + const hasCloudMessage = stdoutCalls.some( + (call) => + typeof call === 'string' && call.includes('n8n Cloud compatibility issues detected'), + ); + expect(hasCloudMessage).toBe(true); + }); + + tmpdirTest('regular lint errors - no cloud suggestion', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + eslintConfig: true, + }); + + mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { + exitCode: 1, + stderr: 'Error: Unexpected token', + }); + + await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1'); + + const stdoutCalls = mockProcessStdout.mock.calls.flat(); + const hasCloudMessage = stdoutCalls.some( + (call) => typeof call === 'string' && call.includes('n8n Cloud compatibility'), + ); + expect(hasCloudMessage).toBe(false); + }); + + tmpdirTest('strict mode with modified config - fails validation', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: true } }, + eslintConfig: + "import { config } from '@n8n/node-cli/eslint';\n\n// Custom modification\nexport default config;\n", + }); + + await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, 'lockfileVersion: 5.4\n'); + + await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1'); + + const stdoutCalls = mockProcessStdout.mock.calls.flat(); + const hasStrictModeError = stdoutCalls.some( + (call) => typeof call === 'string' && call.includes('Strict mode violation:'), + ); + expect(hasStrictModeError).toBe(true); + }); + + tmpdirTest('strict mode with missing config - fails validation', async ({ tmpdir }) => { + await setupTestPackage(tmpdir, { + packageJson: { n8n: { strict: true } }, + }); + + await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, 'lockfileVersion: 5.4\n'); + + // Don't create eslint.config.mjs file (it will be missing) + + await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1'); + + const stdoutCalls = mockProcessStdout.mock.calls.flat(); + const stdout = stdoutCalls + .filter((call) => typeof call === 'string') + .map(stripAnsiCodes) + .join('\n'); + expect(stdout).toContain('eslint.config.mjs not found'); + }); +}); diff --git a/packages/@n8n/node-cli/src/commands/lint.ts b/packages/@n8n/node-cli/src/commands/lint.ts index 446448dbe5f..05d57548490 100644 --- a/packages/@n8n/node-cli/src/commands/lint.ts +++ b/packages/@n8n/node-cli/src/commands/lint.ts @@ -1,9 +1,17 @@ import { Command, Flags } from '@oclif/core'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import picocolors from 'picocolors'; import { ChildProcessError, runCommand } from '../utils/child-process'; +import { suggestCloudSupportCommand } from '../utils/command-suggestions'; +import { getPackageJson } from '../utils/package'; +import { ensureN8nPackage } from '../utils/prompts'; +import { isEnoentError } from '../utils/validation'; export default class Lint extends Command { - static override description = 'Lint the node in the current directory. Includes auto-fixing.'; + static override description = + 'Lint the node in the current directory. Includes auto-fixing. In strict mode, verifies eslint config is unchanged from default.'; static override examples = ['<%= config.bin %> <%= command.id %>']; static override flags = { fix: Flags.boolean({ description: 'Automatically fix problems', default: false }), @@ -12,16 +20,33 @@ export default class Lint extends Command { async run(): Promise { const { flags } = await this.parse(Lint); + await ensureN8nPackage('lint'); + + await this.checkStrictMode(); + const args = ['.']; if (flags.fix) { args.push('--fix'); } + let eslintOutput = ''; try { - await runCommand('eslint', args, { context: 'local', stdio: 'inherit' }); + await runCommand('eslint', args, { + context: 'local', + stdio: 'pipe', + env: { ...process.env, FORCE_COLOR: '1' }, + printOutput: ({ stdout, stderr }) => { + eslintOutput = Buffer.concat([...stdout, ...stderr]).toString(); + process.stdout.write(Buffer.concat(stdout)); + process.stderr.write(Buffer.concat(stderr)); + }, + }); } catch (error: unknown) { if (error instanceof ChildProcessError) { + // Check if error might be related to cloud-only rules + await this.handleLintErrors(eslintOutput); + if (error.signal) { process.kill(process.pid, error.signal); } else { @@ -31,4 +56,84 @@ export default class Lint extends Command { throw error; } } + + private async checkStrictMode(): Promise { + try { + const workingDir = process.cwd(); + const packageJson = await getPackageJson(workingDir); + if (!packageJson?.n8n?.strict) { + return; + } + + await this.verifyEslintConfig(workingDir); + } catch (error) { + return; + } + } + + private async verifyEslintConfig(workingDir: string): Promise { + const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs'); + + const templatePath = path.resolve( + __dirname, + '../template/templates/shared/default/eslint.config.mjs', + ); + const expectedConfig = await fs.readFile(templatePath, 'utf-8'); + + try { + const currentConfig = await fs.readFile(eslintConfigPath, 'utf-8'); + + const normalizedCurrent = currentConfig.replace(/\s+/g, ' ').trim(); + const normalizedExpected = expectedConfig.replace(/\s+/g, ' ').trim(); + + if (normalizedCurrent !== normalizedExpected) { + const enableCommand = await suggestCloudSupportCommand('enable'); + + this.log(`${picocolors.red('Strict mode violation:')} ${picocolors.cyan('eslint.config.mjs')} has been modified from the default configuration. + +${picocolors.dim('Expected:')} +${picocolors.gray(expectedConfig)} + +To restore default config: ${enableCommand} +To disable strict mode: set ${picocolors.yellow('"strict": false')} in ${picocolors.cyan('package.json')} under the ${picocolors.yellow('"n8n"')} section.`); + process.exit(1); + } + } catch (error: unknown) { + if (isEnoentError(error)) { + const enableCommand = await suggestCloudSupportCommand('enable'); + + this.log( + `${picocolors.red('Strict mode violation:')} ${picocolors.cyan('eslint.config.mjs')} not found. Expected default configuration. + +To create default config: ${enableCommand}`, + ); + process.exit(1); + } + throw error; + } + } + + private async handleLintErrors(eslintOutput: string): Promise { + if (this.containsCloudOnlyErrors(eslintOutput)) { + const disableCommand = await suggestCloudSupportCommand('disable'); + + this.log(`${picocolors.yellow('⚠️ n8n Cloud compatibility issues detected')} + +These lint failures prevent verification to n8n Cloud. + +To disable cloud compatibility checks: + ${disableCommand} + +${picocolors.dim(`Note: This will switch to ${picocolors.magenta('configWithoutCloudSupport')} and disable strict mode`)}`); + } + } + + private containsCloudOnlyErrors(errorMessage: string): boolean { + const cloudOnlyRules = [ + '@n8n/eslint-plugin-community-nodes/no-restricted-globals', + '@n8n/eslint-plugin-community-nodes/no-restricted-imports', + ]; + + return cloudOnlyRules.some((rule) => errorMessage.includes(rule)); + } } diff --git a/packages/@n8n/node-cli/src/commands/new/index.test.ts b/packages/@n8n/node-cli/src/commands/new/index.test.ts new file mode 100644 index 00000000000..ac60250c1ca --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/new/index.test.ts @@ -0,0 +1,293 @@ +import fs from 'node:fs/promises'; + +import { CommandTester } from '../../test-utils/command-tester'; +import { mockSpawn, mockExecSync } from '../../test-utils/mock-child-process'; +import { MockPrompt } from '../../test-utils/mock-prompts'; +import { tmpdirTest } from '../../test-utils/temp-fs'; + +vi.mock('../../utils/filesystem', async () => { + const actual = await vi.importActual('../../utils/filesystem'); + return { + ...actual, + delayAtLeast: vi.fn(async (promise: Promise) => await promise), + }; +}); + +describe('new command', () => { + beforeEach(() => { + vi.clearAllMocks(); + MockPrompt.reset(); + }); + + tmpdirTest('creates new node project with user prompts', async ({ tmpdir }) => { + MockPrompt.setup([ + { + question: 'What kind of node are you building?', + answer: 'programmatic', + }, + ]); + + mockExecSync([ + { command: 'git config --get user.name', result: 'Test User\n' }, + { command: 'git config --get user.email', result: 'test@example.com\n' }, + ]); + + mockSpawn([ + { + command: 'git', + args: ['init', '-b', 'main'], + options: { exitCode: 0 }, + }, + { + command: 'pnpm', + args: ['install'], + options: { exitCode: 0 }, + }, + ]); + + await CommandTester.run('new n8n-nodes-my-awesome-api'); + + expect(MockPrompt).toHaveAskedAllQuestions(); + expect(MockPrompt).toHaveAskedQuestion('What kind of node are you building?'); + + expect(tmpdir).toHaveFile('n8n-nodes-my-awesome-api'); + + await expect(tmpdir).toHaveFileContaining( + 'n8n-nodes-my-awesome-api/package.json', + '"name": "n8n-nodes-my-awesome-api"', + ); + await expect(tmpdir).toHaveFileContaining( + 'n8n-nodes-my-awesome-api/package.json', + '"name": "Test User"', + ); + await expect(tmpdir).toHaveFileContaining( + 'n8n-nodes-my-awesome-api/package.json', + '"email": "test@example.com"', + ); + + await expect(tmpdir).toHaveFileContaining( + 'n8n-nodes-my-awesome-api/nodes/Example/Example.node.ts', + 'export class Example implements INodeType', + ); + + // Check if credentials files exist + try { + const credentialsPath = `${tmpdir}/n8n-nodes-my-awesome-api/credentials`; + const credentialFiles = await fs.readdir(credentialsPath); + if (credentialFiles.length > 0) { + await expect(tmpdir).toHaveFileContaining( + `n8n-nodes-my-awesome-api/credentials/${credentialFiles[0]}`, + 'implements ICredentialType', + ); + } + } catch { + // Credentials directory doesn't exist, which is fine + } + }); + + tmpdirTest('creates new node project with node name prompt', async ({ tmpdir }) => { + MockPrompt.setup([ + { + question: "Package name (must start with 'n8n-nodes-' or '@org/n8n-nodes-')", + answer: 'n8n-nodes-interactive-demo', + }, + { + question: 'What kind of node are you building?', + answer: 'declarative', + }, + { + question: 'What template do you want to use?', + answer: 'githubIssues', + }, + ]); + + mockExecSync([ + { command: 'git config --get user.name', result: 'Test User\n' }, + { command: 'git config --get user.email', result: 'test@example.com\n' }, + ]); + + mockSpawn([ + { + command: 'git', + args: ['init', '-b', 'main'], + options: { exitCode: 0 }, + }, + ]); + + await CommandTester.run('new --skip-install'); + + expect(MockPrompt).toHaveAskedAllQuestions(); + + const projectName = 'n8n-nodes-interactive-demo'; + expect(tmpdir).toHaveFile(projectName); + + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"name": "n8n-nodes-interactive-demo"', + ); + await expect(tmpdir).toHaveFileContaining(`${projectName}/package.json`, '"name": "Test User"'); + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"email": "test@example.com"', + ); + + await expect(tmpdir).toHaveFileContaining( + `${projectName}/nodes/GithubIssues/GithubIssues.node.ts`, + 'export class GithubIssues implements INodeType', + ); + + // Check if credentials files exist + try { + const credentialsPath = `${tmpdir}/${projectName}/credentials`; + const credentialFiles = await fs.readdir(credentialsPath); + if (credentialFiles.length > 0) { + await expect(tmpdir).toHaveFileContaining( + `${projectName}/credentials/${credentialFiles[0]}`, + 'implements ICredentialType', + ); + } + } catch { + // Credentials directory doesn't exist, which is fine + } + }); + + tmpdirTest('creates new node project with custom template', async ({ tmpdir }) => { + MockPrompt.setup([ + { + question: 'What kind of node are you building?', + answer: 'declarative', + }, + { + question: 'What template do you want to use?', + answer: 'custom', + }, + { + question: "What's the base URL of the API?", + answer: 'https://api.custom-service.com', + }, + { + question: 'What type of authentication does your API use?', + answer: 'apiKey', + }, + ]); + + mockExecSync([ + { command: 'git config --get user.name', result: 'Custom User\n' }, + { command: 'git config --get user.email', result: 'custom@test.com\n' }, + ]); + + mockSpawn([ + { + command: 'git', + args: ['init', '-b', 'main'], + options: { exitCode: 0 }, + }, + ]); + + await CommandTester.run('new n8n-nodes-custom-api --skip-install'); + + expect(MockPrompt).toHaveAskedAllQuestions(); + + const projectName = 'n8n-nodes-custom-api'; + expect(tmpdir).toHaveFile(projectName); + + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"name": "n8n-nodes-custom-api"', + ); + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"name": "Custom User"', + ); + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"email": "custom@test.com"', + ); + + await expect(tmpdir).toHaveFileContaining( + `${projectName}/nodes/CustomApi/CustomApi.node.ts`, + 'implements INodeType', + ); + + await expect(tmpdir).toHaveFileContaining( + `${projectName}/credentials/CustomApiApi.credentials.ts`, + 'implements ICredentialType', + ); + }); + + test('handles prompt cancellation gracefully', async () => { + MockPrompt.setup([ + { + question: 'What kind of node are you building?', + answer: 'CANCEL', + }, + ]); + + await expect(CommandTester.run('new n8n-nodes-cancelled --skip-install')).rejects.toThrow( + 'EEXIT: 0', + ); + + expect(MockPrompt).toHaveAskedAllQuestions(); + }); + + tmpdirTest( + 'creates new node project with all arguments provided (no prompts)', + async ({ tmpdir }) => { + MockPrompt.setup([]); + + mockExecSync([ + { command: 'git config --get user.name', result: 'No Prompt User\n' }, + { command: 'git config --get user.email', result: 'noprompt@example.com\n' }, + ]); + + mockSpawn([ + { + command: 'git', + args: ['init', '-b', 'main'], + options: { exitCode: 0 }, + }, + ]); + + await CommandTester.run( + 'new n8n-nodes-full-args --template declarative/github-issues --force --skip-install', + ); + + expect(MockPrompt).toHaveAskedAllQuestions(); + + const projectName = 'n8n-nodes-full-args'; + expect(tmpdir).toHaveFile(projectName); + + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"name": "n8n-nodes-full-args"', + ); + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"name": "No Prompt User"', + ); + await expect(tmpdir).toHaveFileContaining( + `${projectName}/package.json`, + '"email": "noprompt@example.com"', + ); + + await expect(tmpdir).toHaveFileContaining( + `${projectName}/nodes/GithubIssues/GithubIssues.node.ts`, + 'export class GithubIssues implements INodeType', + ); + + // Check if credentials files exist + try { + const credentialsPath = `${tmpdir}/${projectName}/credentials`; + const credentialFiles = await fs.readdir(credentialsPath); + if (credentialFiles.length > 0) { + await expect(tmpdir).toHaveFileContaining( + `${projectName}/credentials/${credentialFiles[0]}`, + 'implements ICredentialType', + ); + } + } catch { + // Credentials directory doesn't exist, which is fine + } + }, + ); +}); diff --git a/packages/@n8n/node-cli/src/commands/prerelease.test.ts b/packages/@n8n/node-cli/src/commands/prerelease.test.ts new file mode 100644 index 00000000000..93f70c52e25 --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/prerelease.test.ts @@ -0,0 +1,35 @@ +import { CommandTester } from '../test-utils/command-tester'; + +describe('prerelease command', () => { + const originalEnv = process.env; + const mockProcessStdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + delete process.env.RELEASE_MODE; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test('without RELEASE_MODE - exits with error and shows message', async () => { + await expect(CommandTester.run('prerelease')).rejects.toThrow('EEXIT: 1'); + + const stdoutCalls = mockProcessStdout.mock.calls.flat(); + const hasReleaseMessage = stdoutCalls.some( + (call) => typeof call === 'string' && call.includes('run release` to publish the package'), + ); + expect(hasReleaseMessage).toBe(true); + }); + + test('with RELEASE_MODE - succeeds without logging', async () => { + process.env.RELEASE_MODE = 'true'; + + const result = await CommandTester.run('prerelease'); + + expect(result).toBeDefined(); + expect(mockProcessStdout).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/node-cli/src/commands/prerelease.ts b/packages/@n8n/node-cli/src/commands/prerelease.ts index f82cecec25a..8e150101056 100644 --- a/packages/@n8n/node-cli/src/commands/prerelease.ts +++ b/packages/@n8n/node-cli/src/commands/prerelease.ts @@ -15,7 +15,7 @@ export default class Prerelease extends Command { const packageManager = (await detectPackageManager()) ?? 'npm'; if (!process.env.RELEASE_MODE) { - console.log(`Run \`${packageManager} run release\` to publish the package`); + this.log(`Run \`${packageManager} run release\` to publish the package`); process.exit(1); } } diff --git a/packages/@n8n/node-cli/src/commands/release.test.ts b/packages/@n8n/node-cli/src/commands/release.test.ts new file mode 100644 index 00000000000..3c73b38d741 --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/release.test.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs/promises'; + +import { CommandTester } from '../test-utils/command-tester'; +import { mockSpawn } from '../test-utils/mock-child-process'; +import { tmpdirTest } from '../test-utils/temp-fs'; + +describe('release command', () => { + const originalEnv = process.env; + + const releaseItArgs = [ + 'exec', + '--', + 'release-it', + '-n', + '--git.requireBranch main', + '--git.requireCleanWorkingDir', + '--git.requireUpstream', + '--git.requireCommits', + '--git.commit', + '--git.tag', + '--git.push', + '--git.changelog="npx auto-changelog --stdout --unreleased --commit-limit false -u --hide-credit"', + '--github.release', + ]; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + delete process.env.npm_config_user_agent; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + tmpdirTest('successful release - runs release-it with correct arguments', async ({ tmpdir }) => { + await fs.writeFile( + `${tmpdir}/package.json`, + JSON.stringify({ + name: 'test-node', + version: '1.0.0', + n8n: { + nodes: ['dist/nodes/TestNode.node.js'], + }, + }), + ); + await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, '# pnpm lock file'); + + mockSpawn( + 'pnpm', + [ + ...releaseItArgs, + '--hooks.before:init="pnpm run lint && pnpm run build"', + '--hooks.after:bump="npx auto-changelog -p"', + ], + { exitCode: 0 }, + ); + + const result = await CommandTester.run('release'); + + expect(result).toBeDefined(); + }); + + tmpdirTest('release-it failure - exits with error code', async ({ tmpdir }) => { + await fs.writeFile( + `${tmpdir}/package.json`, + JSON.stringify({ + name: 'test-node', + version: '1.0.0', + n8n: { + nodes: ['dist/nodes/TestNode.node.js'], + }, + }), + ); + + mockSpawn('npm', expect.any(Array) as string[], { + exitCode: 1, + stderr: 'Release failed: Git working directory is not clean', + }); + + await expect(CommandTester.run('release')).rejects.toThrow('EEXIT: 1'); + }); +}); diff --git a/packages/@n8n/node-cli/src/configs/eslint.ts b/packages/@n8n/node-cli/src/configs/eslint.ts index 171622bba66..078d34d4df7 100644 --- a/packages/@n8n/node-cli/src/configs/eslint.ts +++ b/packages/@n8n/node-cli/src/configs/eslint.ts @@ -1,59 +1,70 @@ -// Included with peer dependency eslint -// eslint-disable-next-line import-x/no-extraneous-dependencies import eslint from '@eslint/js'; +import { n8nCommunityNodesPlugin } from '@n8n/eslint-plugin-community-nodes'; import { globalIgnores } from 'eslint/config'; import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; import importPlugin from 'eslint-plugin-import-x'; import n8nNodesPlugin from 'eslint-plugin-n8n-nodes-base'; import tseslint, { type ConfigArray } from 'typescript-eslint'; -export const config: ConfigArray = tseslint.config( - globalIgnores(['dist']), - { - files: ['**/*.ts'], - extends: [ - eslint.configs.recommended, - tseslint.configs.recommended, - importPlugin.configs['flat/recommended'], - ], - rules: { - 'prefer-spread': 'off', - }, - }, - { - plugins: { 'n8n-nodes-base': n8nNodesPlugin }, - settings: { - 'import-x/resolver-next': [createTypeScriptImportResolver()], - }, - }, - { - files: ['package.json'], - rules: { - ...n8nNodesPlugin.configs.community.rules, - }, - languageOptions: { - parser: tseslint.parser, - parserOptions: { - extraFileExtensions: ['.json'], +function createConfig(supportCloud = true): ConfigArray { + return tseslint.config( + globalIgnores(['dist']), + { + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + tseslint.configs.recommended, + supportCloud + ? n8nCommunityNodesPlugin.configs.recommended + : n8nCommunityNodesPlugin.configs.recommendedWithoutN8nCloudSupport, + importPlugin.configs['flat/recommended'], + ], + rules: { + 'prefer-spread': 'off', }, }, - }, - { - files: ['./credentials/**/*.ts'], - rules: { - ...n8nNodesPlugin.configs.credentials.rules, - 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off', + { + plugins: { 'n8n-nodes-base': n8nNodesPlugin }, + settings: { + 'import-x/resolver-next': [createTypeScriptImportResolver()], + }, }, - }, - { - files: ['./nodes/**/*.ts'], - rules: { - ...n8nNodesPlugin.configs.nodes.rules, - 'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off', - 'n8n-nodes-base/node-class-description-outputs-wrong': 'off', - 'n8n-nodes-base/node-param-type-options-max-value-present': 'off', + { + files: ['package.json'], + rules: { + ...n8nNodesPlugin.configs.community.rules, + }, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + extraFileExtensions: ['.json'], + }, + }, }, - }, -); + { + files: ['./credentials/**/*.ts'], + rules: { + ...n8nNodesPlugin.configs.credentials.rules, + // Not valid for community nodes + 'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off', + // @n8n/eslint-plugin-community-nodes credential-password-field rule is more accurate + 'n8n-nodes-base/cred-class-field-type-options-password-missing': 'off', + }, + }, + { + files: ['./nodes/**/*.ts'], + rules: { + ...n8nNodesPlugin.configs.nodes.rules, + // Inputs and outputs can be enum instead of string "main" + 'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off', + 'n8n-nodes-base/node-class-description-outputs-wrong': 'off', + // Sometimes the 3rd party API does have a maximum limit, so maxValue is valid + 'n8n-nodes-base/node-param-type-options-max-value-present': 'off', + }, + }, + ); +} +export const config = createConfig(); +export const configWithoutCloudSupport = createConfig(false); export default config; diff --git a/packages/@n8n/node-cli/src/index.ts b/packages/@n8n/node-cli/src/index.ts index c45bbb57d65..70f979e8b2f 100644 --- a/packages/@n8n/node-cli/src/index.ts +++ b/packages/@n8n/node-cli/src/index.ts @@ -1,4 +1,5 @@ import Build from './commands/build'; +import CloudSupport from './commands/cloud-support'; import Dev from './commands/dev'; import Lint from './commands/lint'; import New from './commands/new'; @@ -12,4 +13,6 @@ export const commands = { prerelease: Prerelease, release: Release, lint: Lint, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'cloud-support': CloudSupport, }; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json index ae95e34ba6d..3c94dfc92a5 100644 --- a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json @@ -1,9 +1,9 @@ { "name": "{{nodePackageName}}", "version": "0.1.0", - "description": "n8n community node to work with the Example API", + "description": "", "license": "MIT", - "homepage": "https://example.com", + "homepage": "", "keywords": [ "n8n-community-node-package" ], @@ -13,7 +13,7 @@ }, "repository": { "type": "git", - "url": "" + "url": "https://github.com/<...>/n8n-nodes-<...>.git" }, "scripts": { "build": "n8n-node build", @@ -29,6 +29,7 @@ ], "n8n": { "n8nNodesApiVersion": 1, + "strict": true, "credentials": [], "nodes": [ "dist/nodes/Example/Example.node.js" diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/eslint.config.mjs b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/eslint.config.mjs deleted file mode 100644 index ad811a0baf0..00000000000 --- a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { config } from '@n8n/node-cli/eslint'; - -export default config; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json index 96961a99f9d..e1557398974 100644 --- a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json @@ -1,9 +1,9 @@ { "name": "{{nodePackageName}}", "version": "0.1.0", - "description": "n8n community node to work with the GitHub Issues API", + "description": "", "license": "MIT", - "homepage": "https://example.com", + "homepage": "", "keywords": [ "n8n-community-node-package" ], @@ -13,7 +13,7 @@ }, "repository": { "type": "git", - "url": "" + "url": "https://github.com/<...>/n8n-nodes-<...>.git" }, "scripts": { "build": "n8n-node build", @@ -29,6 +29,7 @@ ], "n8n": { "n8nNodesApiVersion": 1, + "strict": true, "credentials": [ "dist/credentials/GithubIssuesApi.credentials.js", "dist/credentials/GithubIssuesOAuth2Api.credentials.js" diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/eslint.config.mjs b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/eslint.config.mjs deleted file mode 100644 index ad811a0baf0..00000000000 --- a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { config } from '@n8n/node-cli/eslint'; - -export default config; diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json index 8f0829db623..78d972e6664 100644 --- a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json @@ -1,9 +1,9 @@ { "name": "{{nodePackageName}}", "version": "0.1.0", - "description": "Example n8n community node", + "description": "", "license": "MIT", - "homepage": "https://example.com", + "homepage": "", "keywords": [ "n8n-community-node-package" ], @@ -13,7 +13,7 @@ }, "repository": { "type": "git", - "url": "" + "url": "https://github.com/<...>/n8n-nodes-<...>.git" }, "scripts": { "build": "n8n-node build", @@ -29,6 +29,7 @@ ], "n8n": { "n8nNodesApiVersion": 1, + "strict": true, "credentials": [], "nodes": [ "dist/nodes/Example/Example.node.js" diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/eslint.config.mjs b/packages/@n8n/node-cli/src/template/templates/shared/default/eslint.config.mjs similarity index 100% rename from packages/@n8n/node-cli/src/template/templates/declarative/custom/template/eslint.config.mjs rename to packages/@n8n/node-cli/src/template/templates/shared/default/eslint.config.mjs diff --git a/packages/@n8n/node-cli/src/test-utils/command-tester.ts b/packages/@n8n/node-cli/src/test-utils/command-tester.ts new file mode 100644 index 00000000000..c400ca3b63b --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/command-tester.ts @@ -0,0 +1,49 @@ +import { log } from '@clack/prompts'; +import type { Config } from '@oclif/core'; +import { mock } from 'vitest-mock-extended'; + +import { commands } from '../index'; + +function isValidCommand(commandName: string): commandName is keyof typeof commands { + return commandName in commands; +} + +export type LogLevel = 'success' | 'warning' | 'error' | 'info'; + +export interface CommandResult { + getLogMessages(type: LogLevel): string[]; +} + +export class CommandTester { + static async run(commandLine: string): Promise { + const argv = commandLine.trim().split(/\s+/); + const [commandName, ...restArgv] = argv; + + if (!isValidCommand(commandName)) { + throw new Error( + `Unknown command: ${commandName}. Available: ${Object.keys(commands).join(', ')}`, + ); + } + + const CommandClass = commands[commandName]; + + const command = new CommandClass( + restArgv, + mock({ + root: process.cwd(), + name: '@n8n/node-cli', + version: '1.0.0', + runHook: async () => await Promise.resolve({ successes: [], failures: [] }), + }), + ); + + await command.run(); + + return { + getLogMessages(type: LogLevel): string[] { + const mockFn = vi.mocked(log[type]); + return mockFn.mock.calls?.map((call) => call[0]) ?? []; + }, + }; + } +} diff --git a/packages/@n8n/node-cli/src/test-utils/index.ts b/packages/@n8n/node-cli/src/test-utils/index.ts new file mode 100644 index 00000000000..d33278444ea --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/index.ts @@ -0,0 +1,12 @@ +export { CommandTester, type CommandResult, type LogLevel } from './command-tester'; +export { + mockSpawn, + mockExecSync, + type MockChildProcess, + type MockSpawnOptions, + type CommandMockConfig, + type ExecSyncMockConfig, +} from './mock-child-process'; +export { tmpdirTest } from './temp-fs'; +export { MockPrompt } from './mock-prompts'; +export { setupTestPackage, type PackageSetupOptions } from './package-setup'; diff --git a/packages/@n8n/node-cli/src/test-utils/matchers.ts b/packages/@n8n/node-cli/src/test-utils/matchers.ts new file mode 100644 index 00000000000..d9981502d13 --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/matchers.ts @@ -0,0 +1,190 @@ +import fsSync from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { expect } from 'vitest'; + +import type { CommandResult } from './command-tester'; +import type { MockPrompt } from './mock-prompts'; +import { isEnoentError } from '../utils/validation'; + +export function stripAnsiCodes(text: string): string { + // Need to strip ANSI escape codes for colors and styles + // eslint-disable-next-line no-control-regex + return text.replace(/\u001b\[.*?m/g, ''); +} + +function createLogMatcher(logLevel: 'success' | 'warning' | 'error') { + return function (received: CommandResult, expected: string) { + const messages = received.getLogMessages(logLevel); + const cleanMessages = messages.map(stripAnsiCodes); + const hasMessage = cleanMessages.some((msg) => msg.includes(expected)); + + return { + pass: hasMessage, + message: () => + hasMessage + ? `Expected command NOT to log ${logLevel} message containing "${expected}"` + : `Expected command to log ${logLevel} message containing "${expected}". Got: ${cleanMessages.join(', ')}`, + }; + }; +} + +expect.extend({ + toHaveLoggedSuccess: createLogMatcher('success'), + toHaveLoggedWarning: createLogMatcher('warning'), + toHaveLoggedError: createLogMatcher('error'), + toHaveFile(received: string, filename: string) { + const fullPath = path.resolve(received, filename); + const exists = fsSync.existsSync(fullPath); + + return { + pass: exists, + message: () => + exists + ? `Expected file "${filename}" NOT to exist` + : `Expected file "${filename}" to exist`, + }; + }, + async toHaveFileEqual(received: string, filename: string, expectedContent?: string) { + const fullPath = path.resolve(received, filename); + let content: string | undefined; + + try { + content = await fs.readFile(fullPath, 'utf8'); + } catch (error) { + if (isEnoentError(error)) { + content = undefined; + } else { + throw error; + } + } + + if (content === undefined) { + return { + pass: false, + message: () => `Expected file "${filename}" to exist`, + }; + } + + if (expectedContent !== undefined && content !== expectedContent) { + return { + pass: false, + message: () => + `Expected file "${filename}" to have content "${expectedContent}". Got: "${content}"`, + }; + } + + return { + pass: true, + message: () => + expectedContent !== undefined + ? `Expected file "${filename}" NOT to have content "${expectedContent}"` + : `Expected file "${filename}" NOT to exist`, + }; + }, + async toHaveFileContaining(received: string, filename: string, text: string) { + const fullPath = path.resolve(received, filename); + let content: string | undefined; + + try { + content = await fs.readFile(fullPath, 'utf8'); + } catch (error) { + if (isEnoentError(error)) { + content = undefined; + } else { + throw error; + } + } + + const contains = content?.includes(text) ?? false; + + return { + pass: contains, + message: () => + contains + ? `Expected file "${filename}" NOT to contain "${text}"` + : `Expected file "${filename}" to contain "${text}". File content: "${content ?? 'File not found'}"`, + }; + }, + async toHaveFileMatchingPattern(received: string, filename: string, pattern: RegExp) { + const fullPath = path.resolve(received, filename); + let content: string | undefined; + + try { + content = await fs.readFile(fullPath, 'utf8'); + } catch (error) { + if (isEnoentError(error)) { + content = undefined; + } else { + throw error; + } + } + + const matches = content ? new RegExp(pattern).test(content) : false; + + return { + pass: matches, + message: () => + matches + ? `Expected file "${filename}" NOT to match pattern ${pattern.toString()}` + : `Expected file "${filename}" to match pattern ${pattern.toString()}. File content: "${content ?? 'File not found'}"`, + }; + }, + toNotHaveFile(received: string, filename: string) { + const fullPath = path.resolve(received, filename); + const exists = fsSync.existsSync(fullPath); + + return { + pass: !exists, + message: () => + exists + ? `Expected file "${filename}" NOT to exist` + : `Expected file "${filename}" to exist`, + }; + }, + toHaveAskedAllQuestions(received: typeof MockPrompt) { + const questionAnswers = received['questionAnswers']; + const askedQuestions = received['askedQuestions']; + + const expectedQuestions = Array.from(questionAnswers.keys()); + const askedQuestionsArray = Array.from(askedQuestions); + const unaskedQuestions = expectedQuestions.filter((q) => !askedQuestions.has(q)); + + const allAsked = unaskedQuestions.length === 0; + + return { + pass: allAsked, + message: () => + allAsked + ? 'Expected some questions to remain unasked' + : `Expected questions were not asked: ${unaskedQuestions.join(', ')}\nExpected: [${expectedQuestions.join(', ')}]\nAsked: [${askedQuestionsArray.join(', ')}]`, + }; + }, + toHaveAskedQuestion(received: typeof MockPrompt, question: string) { + const askedQuestions = received.getAskedQuestions(); + const wasAsked = askedQuestions.includes(question); + + return { + pass: wasAsked, + message: () => + wasAsked + ? `Expected question "${question}" NOT to have been asked` + : `Expected question "${question}" to have been asked. Asked questions: [${askedQuestions.join(', ')}]`, + }; + }, +}); + +declare module 'vitest' { + interface Assertion { + toHaveLoggedSuccess(message: string): T; + toHaveLoggedWarning(message: string): T; + toHaveLoggedError(message: string): T; + toHaveFile(filename: string): T; + toHaveFileEqual(filename: string, expectedContent?: string): Promise; + toHaveFileContaining(filename: string, text: string): Promise; + toHaveFileMatchingPattern(filename: string, pattern: RegExp): Promise; + toNotHaveFile(filename: string): T; + toHaveAskedAllQuestions(): T; + toHaveAskedQuestion(question: string): T; + } +} diff --git a/packages/@n8n/node-cli/src/test-utils/mock-child-process.ts b/packages/@n8n/node-cli/src/test-utils/mock-child-process.ts new file mode 100644 index 00000000000..267d24d1c1f --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/mock-child-process.ts @@ -0,0 +1,122 @@ +import { spawn, execSync, type ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +export interface MockChildProcess extends EventEmitter { + stdout: EventEmitter | null; + stderr: EventEmitter | null; +} + +export interface MockSpawnOptions { + exitCode?: number; + signal?: NodeJS.Signals; + stdout?: string; + stderr?: string; + error?: string; +} + +export interface CommandMockConfig { + command: string; + args: string[]; + options?: MockSpawnOptions; +} + +function createMockProcess(): ChildProcess { + const emitter = new EventEmitter(); + const mockProcess: MockChildProcess = Object.assign(emitter, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + return mockProcess as unknown as ChildProcess; +} + +function emitProcessEvents(mockProcess: MockChildProcess, options: MockSpawnOptions): void { + const { + exitCode = options.signal ? null : 0, + signal = null, + stdout = '', + stderr = '', + error, + } = options; + + setImmediate(() => { + if (error) { + mockProcess.emit('error', new Error(error)); + setImmediate(() => { + mockProcess.emit('close', exitCode !== 0 ? exitCode : 1, signal); + }); + return; + } + + if (stdout && mockProcess.stdout) { + mockProcess.stdout.emit('data', Buffer.from(stdout)); + } + if (stderr && mockProcess.stderr) { + mockProcess.stderr.emit('data', Buffer.from(stderr)); + } + + mockProcess.emit('close', exitCode, signal); + }); +} + +export function mockSpawn(command: string, args: string[], options?: MockSpawnOptions): void; +export function mockSpawn(commands: CommandMockConfig[]): void; +export function mockSpawn( + commandOrCommands: string | CommandMockConfig[], + args?: string[], + options?: MockSpawnOptions, +): void { + if (Array.isArray(commandOrCommands)) { + const commands = commandOrCommands; + let callIndex = 0; + + vi.mocked(spawn).mockImplementation((cmd, cmdArgs): ChildProcess => { + if (callIndex >= commands.length) { + throw new Error(`Unexpected spawn call: ${cmd} ${cmdArgs?.join(' ')}`); + } + + const expectedConfig = commands[callIndex]; + expect(cmd).toBe(expectedConfig.command); + expect(cmdArgs).toEqual(expectedConfig.args); + + const mockProcess = createMockProcess(); + const options = expectedConfig.options ?? {}; + + emitProcessEvents(mockProcess, options); + + callIndex++; + return mockProcess; + }); + } else { + const command = commandOrCommands; + if (!args) throw new Error('args required for single command mock'); + + vi.mocked(spawn).mockImplementation((cmd, cmdArgs): ChildProcess => { + expect(cmd).toBe(command); + expect(cmdArgs).toEqual(args); + + const mockProcess = createMockProcess(); + const mockOptions = options ?? {}; + + emitProcessEvents(mockProcess, mockOptions); + + return mockProcess; + }); + } +} + +export interface ExecSyncMockConfig { + command: string; + result: string; +} + +export function mockExecSync(configs: ExecSyncMockConfig[]): void { + const configMap = new Map(configs.map((c) => [c.command, c.result])); + + vi.mocked(execSync).mockImplementation((command) => { + const result = configMap.get(String(command)); + if (result === undefined) { + throw new Error(`Unexpected execSync call: ${command}`); + } + return Buffer.from(result); + }); +} diff --git a/packages/@n8n/node-cli/src/test-utils/mock-prompts.ts b/packages/@n8n/node-cli/src/test-utils/mock-prompts.ts new file mode 100644 index 00000000000..d7b239d06f5 --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/mock-prompts.ts @@ -0,0 +1,87 @@ +import { confirm, isCancel, text, select } from '@clack/prompts'; + +interface PromptConfig { + message: string; + placeholder?: string; + defaultValue?: string; + options?: Array<{ label: string; value: unknown; hint?: string }>; +} + +type PromptAnswer = T | 'CANCEL'; + +interface QuestionAnswerPair { + question: string | Partial; + answer: PromptAnswer; +} + +export class MockPrompt { + private static readonly questionAnswers = new Map(); + private static readonly askedQuestions = new Set(); + + static setup(pairs: QuestionAnswerPair[]): void { + MockPrompt.reset(); + + for (const { question, answer } of pairs) { + const key = typeof question === 'string' ? question : question.message!; + MockPrompt.questionAnswers.set(key, answer); + } + + MockPrompt.setupMocks(); + } + + static reset(): void { + vi.mocked(confirm).mockReset(); + vi.mocked(text).mockReset(); + vi.mocked(select).mockReset(); + vi.mocked(isCancel).mockReset(); + MockPrompt.questionAnswers.clear(); + MockPrompt.askedQuestions.clear(); + } + + static getAskedQuestions(): string[] { + return Array.from(MockPrompt.askedQuestions); + } + + private static setupMocks(): void { + vi.mocked(select).mockImplementation(async (config) => { + MockPrompt.askedQuestions.add(config.message); + const answer = MockPrompt.questionAnswers.get(config.message); + if (answer === undefined) { + throw new Error(`No mock answer configured for select question: "${config.message}"`); + } + if (answer === 'CANCEL') { + return await Promise.resolve(Symbol('cancel')); + } + return answer; + }); + + vi.mocked(text).mockImplementation(async (config) => { + MockPrompt.askedQuestions.add(config.message); + const answer = MockPrompt.questionAnswers.get(config.message); + if (answer === undefined) { + throw new Error(`No mock answer configured for text question: "${config.message}"`); + } + if (answer === 'CANCEL') { + return await Promise.resolve(Symbol('cancel')); + } + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return String(answer); + }); + + vi.mocked(confirm).mockImplementation(async (config) => { + MockPrompt.askedQuestions.add(config.message); + const answer = MockPrompt.questionAnswers.get(config.message); + if (answer === undefined) { + throw new Error(`No mock answer configured for confirm question: "${config.message}"`); + } + if (answer === 'CANCEL') { + return await Promise.resolve(Symbol('cancel')); + } + return Boolean(answer); + }); + + vi.mocked(isCancel).mockImplementation((value) => { + return typeof value === 'symbol' && value.description === 'cancel'; + }); + } +} diff --git a/packages/@n8n/node-cli/src/test-utils/package-setup.ts b/packages/@n8n/node-cli/src/test-utils/package-setup.ts new file mode 100644 index 00000000000..a92c048a93d --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/package-setup.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises'; + +import type { N8nPackageJson } from '../utils/package'; + +export interface PackageSetupOptions { + packageJson?: Partial; + eslintConfig?: string | boolean; +} + +const DEFAULT_PACKAGE_CONFIG: N8nPackageJson = { + name: 'test-node', + version: '1.0.0', + n8n: { + nodes: ['dist/nodes/TestNode.node.js'], + strict: true, + }, +}; + +const DEFAULT_ESLINT_CONFIG = + "import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n"; + +export async function setupTestPackage( + tmpdir: string, + options: PackageSetupOptions = {}, +): Promise { + const packageConfig = { + ...DEFAULT_PACKAGE_CONFIG, + ...options.packageJson, + n8n: { + ...DEFAULT_PACKAGE_CONFIG.n8n, + ...options.packageJson?.n8n, + }, + }; + await fs.writeFile(`${tmpdir}/package.json`, JSON.stringify(packageConfig, null, 2)); + + if (options.eslintConfig === true) { + await fs.writeFile(`${tmpdir}/eslint.config.mjs`, DEFAULT_ESLINT_CONFIG); + } else if (typeof options.eslintConfig === 'string') { + await fs.writeFile(`${tmpdir}/eslint.config.mjs`, options.eslintConfig); + } +} diff --git a/packages/@n8n/node-cli/src/test-utils/setup.ts b/packages/@n8n/node-cli/src/test-utils/setup.ts new file mode 100644 index 00000000000..a722bced6aa --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/setup.ts @@ -0,0 +1,28 @@ +import './matchers'; + +vi.mock('node:child_process'); +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + note: vi.fn(), + log: { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), + })), + confirm: vi.fn(), + text: vi.fn(), + select: vi.fn(), + isCancel: vi.fn(), +})); + +vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new Error(`EEXIT: ${code ?? 0}`); +}); diff --git a/packages/@n8n/node-cli/src/test-utils/temp-fs.ts b/packages/@n8n/node-cli/src/test-utils/temp-fs.ts new file mode 100644 index 00000000000..2770ad5e047 --- /dev/null +++ b/packages/@n8n/node-cli/src/test-utils/temp-fs.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; + +async function createTempDir(): Promise { + const ostmpdir = os.tmpdir(); + const tmpdir = path.join(ostmpdir, 'n8n-node-cli-test-'); + return await fs.mkdtemp(tmpdir); +} + +interface TmpDirFixture { + tmpdir: string; +} + +export const tmpdirTest = test.extend({ + tmpdir: async ({ expect: _expect }, use) => { + const directory = await createTempDir(); + const originalCwd = process.cwd(); + + process.chdir(directory); + + try { + await use(directory); + } finally { + process.chdir(originalCwd); + await fs.rm(directory, { recursive: true, force: true }); + } + }, +}); diff --git a/packages/@n8n/node-cli/src/utils/child-process.ts b/packages/@n8n/node-cli/src/utils/child-process.ts index 9ec4112afde..349ff535418 100644 --- a/packages/@n8n/node-cli/src/utils/child-process.ts +++ b/packages/@n8n/node-cli/src/utils/child-process.ts @@ -66,10 +66,10 @@ export async function runCommand( }); child.on('close', (code, signal) => { + printOutput(); if (code === 0) { resolve(); } else { - printOutput(); reject( new ChildProcessError( `${cmd} exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`, diff --git a/packages/@n8n/node-cli/src/utils/command-suggestions.ts b/packages/@n8n/node-cli/src/utils/command-suggestions.ts new file mode 100644 index 00000000000..8c6522f83d0 --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/command-suggestions.ts @@ -0,0 +1,29 @@ +import picocolors from 'picocolors'; + +import { detectPackageManager } from './package-manager'; + +type ExecCommandType = 'cli' | 'script'; + +export async function getExecCommand(type: ExecCommandType = 'cli'): Promise { + const packageManager = (await detectPackageManager()) ?? 'npm'; + + if (type === 'script') { + return packageManager === 'npm' ? 'npm run' : packageManager; + } + + return packageManager === 'npm' ? 'npx' : packageManager; +} + +export function formatCommand(command: string): string { + return picocolors.cyan(command); +} + +export async function suggestCloudSupportCommand(action: 'enable' | 'disable'): Promise { + const execCommand = await getExecCommand('cli'); + return formatCommand(`${execCommand} n8n-node cloud-support ${action}`); +} + +export async function suggestLintCommand(): Promise { + const execCommand = await getExecCommand('script'); + return formatCommand(`${execCommand} lint`); +} diff --git a/packages/@n8n/node-cli/src/utils/git.test.ts b/packages/@n8n/node-cli/src/utils/git.test.ts index e9c9c33f2bf..58300ad4252 100644 --- a/packages/@n8n/node-cli/src/utils/git.test.ts +++ b/packages/@n8n/node-cli/src/utils/git.test.ts @@ -1,8 +1,8 @@ -import { execSync } from 'child_process'; +import { execSync } from 'node:child_process'; import { tryReadGitUser } from './git'; -vi.mock('child_process'); +vi.mock('node:child_process'); describe('git utils', () => { describe('tryReadGitUser', () => { diff --git a/packages/@n8n/node-cli/src/utils/git.ts b/packages/@n8n/node-cli/src/utils/git.ts index 53fa095cd69..a79d419a7ab 100644 --- a/packages/@n8n/node-cli/src/utils/git.ts +++ b/packages/@n8n/node-cli/src/utils/git.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execSync } from 'node:child_process'; import { runCommand } from './child-process'; diff --git a/packages/@n8n/node-cli/src/utils/package-manager.test.ts b/packages/@n8n/node-cli/src/utils/package-manager.test.ts index 6743437ed3e..9b4f6fdcc60 100644 --- a/packages/@n8n/node-cli/src/utils/package-manager.test.ts +++ b/packages/@n8n/node-cli/src/utils/package-manager.test.ts @@ -1,14 +1,7 @@ -import type { Stats } from 'node:fs'; import fs from 'node:fs/promises'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; import { detectPackageManager, detectPackageManagerFromUserAgent } from './package-manager'; - -// Mock dependencies -vi.mock('node:child_process'); -vi.mock('node:fs/promises'); -vi.mock('@clack/prompts'); +import { tmpdirTest } from '../test-utils/temp-fs'; describe('package manager utils', () => { const originalEnv = process.env; @@ -95,98 +88,73 @@ describe('package manager utils', () => { const result = await detectPackageManager(); expect(result).toBe('pnpm'); - expect(vi.mocked(fs).stat).not.toHaveBeenCalled(); }); - it('detects npm from package-lock.json when user agent is not available', async () => { + tmpdirTest( + 'detects npm from package-lock.json when user agent is not available', + async ({ tmpdir }) => { + delete process.env.npm_config_user_agent; + + await fs.writeFile(`${tmpdir}/package-lock.json`, '{}'); + + const result = await detectPackageManager(); + + expect(result).toBe('npm'); + }, + ); + + tmpdirTest( + 'detects yarn from yarn.lock when user agent is not available', + async ({ tmpdir }) => { + delete process.env.npm_config_user_agent; + + await fs.writeFile(`${tmpdir}/yarn.lock`, ''); + + const result = await detectPackageManager(); + + expect(result).toBe('yarn'); + }, + ); + + tmpdirTest( + 'detects pnpm from pnpm-lock.yaml when user agent is not available', + async ({ tmpdir }) => { + delete process.env.npm_config_user_agent; + + await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, ''); + + const result = await detectPackageManager(); + + expect(result).toBe('pnpm'); + }, + ); + + tmpdirTest('prioritizes npm lock file when multiple lock files exist', async ({ tmpdir }) => { delete process.env.npm_config_user_agent; - vi.mocked(fs).stat.mockImplementation(async (path) => { - if (path === 'package-lock.json') { - const stats = mock(); - stats.isFile.mockReturnValue(true); - return await Promise.resolve(stats); - } - throw new Error('File not found'); - }); - - const result = await detectPackageManager(); - - expect(result).toBe('npm'); - expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json'); - }); - - it('detects yarn from yarn.lock when user agent is not available', async () => { - delete process.env.npm_config_user_agent; - - vi.mocked(fs).stat.mockImplementation(async (path) => { - if (path === 'yarn.lock') { - const stats = mock(); - stats.isFile.mockReturnValue(true); - return await Promise.resolve(stats); - } - throw new Error('File not found'); - }); - - const result = await detectPackageManager(); - - expect(result).toBe('yarn'); - expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json'); - expect(vi.mocked(fs).stat).toHaveBeenCalledWith('yarn.lock'); - }); - - it('detects pnpm from pnpm-lock.yaml when user agent is not available', async () => { - delete process.env.npm_config_user_agent; - - vi.mocked(fs).stat.mockImplementation(async (path) => { - if (path === 'pnpm-lock.yaml') { - const stats = mock(); - stats.isFile.mockReturnValue(true); - return await Promise.resolve(stats); - } - throw new Error('File not found'); - }); - - const result = await detectPackageManager(); - - expect(result).toBe('pnpm'); - expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json'); - expect(vi.mocked(fs).stat).toHaveBeenCalledWith('yarn.lock'); - expect(vi.mocked(fs).stat).toHaveBeenCalledWith('pnpm-lock.yaml'); - }); - - it('prioritizes npm lock file when multiple lock files exist', async () => { - delete process.env.npm_config_user_agent; - - const stats = mock(); - stats.isFile.mockReturnValue(true); - vi.mocked(fs).stat.mockResolvedValue(stats); + await fs.writeFile(`${tmpdir}/package-lock.json`, '{}'); + await fs.writeFile(`${tmpdir}/yarn.lock`, ''); + await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, ''); const result = await detectPackageManager(); expect(result).toBe('npm'); }); - it('returns null when no user agent and no lock files exist', async () => { + tmpdirTest('returns null when no user agent and no lock files exist', async () => { delete process.env.npm_config_user_agent; - vi.mocked(fs).stat.mockRejectedValue(new Error('File not found')); const result = await detectPackageManager(); expect(result).toBe(null); }); - it('ignores directories that match lock file names', async () => { + tmpdirTest('ignores directories that match lock file names', async ({ tmpdir }) => { delete process.env.npm_config_user_agent; - vi.mocked(fs).stat.mockImplementation(async (path) => { - if (path === 'package-lock.json') { - const stats = mock(); - stats.isFile.mockReturnValue(false); - return await Promise.resolve(stats); - } - throw new Error('File not found'); - }); + await fs.mkdir(`${tmpdir}/package-lock.json`); + await fs.mkdir(`${tmpdir}/yarn.lock`); + await fs.mkdir(`${tmpdir}/pnpm-lock.yaml`); const result = await detectPackageManager(); diff --git a/packages/@n8n/node-cli/src/utils/package.ts b/packages/@n8n/node-cli/src/utils/package.ts index fecb4a74aa0..04a1738f28b 100644 --- a/packages/@n8n/node-cli/src/utils/package.ts +++ b/packages/@n8n/node-cli/src/utils/package.ts @@ -5,12 +5,13 @@ import prettier from 'prettier'; import { writeFileSafe } from './filesystem'; import { jsonParse } from './json'; -type N8nPackageJson = { +export type N8nPackageJson = { name: string; version: string; n8n?: { nodes?: string[]; credentials?: string[]; + strict?: boolean; }; }; diff --git a/packages/@n8n/node-cli/src/utils/validation.ts b/packages/@n8n/node-cli/src/utils/validation.ts index 76295bc0ae6..c0222a76379 100644 --- a/packages/@n8n/node-cli/src/utils/validation.ts +++ b/packages/@n8n/node-cli/src/utils/validation.ts @@ -11,3 +11,11 @@ export const validateNodeName = (name: string): string | undefined => { } return; }; + +export function isNodeErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +export function isEnoentError(error: unknown): boolean { + return isNodeErrnoException(error) && error.code === 'ENOENT'; +} diff --git a/packages/@n8n/node-cli/tsconfig.build.json b/packages/@n8n/node-cli/tsconfig.build.json index 9bb94c6060a..678cb3d92db 100644 --- a/packages/@n8n/node-cli/tsconfig.build.json +++ b/packages/@n8n/node-cli/tsconfig.build.json @@ -10,6 +10,7 @@ "include": ["src/**/*.ts"], "exclude": [ "src/**/*.test.ts", + "src/test-utils/**/*", "src/template/templates/**/template", "src/template/templates/shared" ] diff --git a/packages/@n8n/node-cli/vite.config.ts b/packages/@n8n/node-cli/vite.config.ts index 9a88a62d2be..fe333e599cb 100644 --- a/packages/@n8n/node-cli/vite.config.ts +++ b/packages/@n8n/node-cli/vite.config.ts @@ -1,3 +1,9 @@ import { defineConfig } from 'vitest/config'; -export default defineConfig({ test: { globals: true, disableConsoleIntercept: true } }); +export default defineConfig({ + test: { + globals: true, + disableConsoleIntercept: true, + setupFiles: ['src/test-utils/setup.ts'], + }, +}); diff --git a/packages/@n8n/nodes-langchain/credentials/AzureEntraCognitiveServicesOAuth2Api.credentials.ts b/packages/@n8n/nodes-langchain/credentials/AzureEntraCognitiveServicesOAuth2Api.credentials.ts index 900bdc11aa5..3fb26d290d0 100644 --- a/packages/@n8n/nodes-langchain/credentials/AzureEntraCognitiveServicesOAuth2Api.credentials.ts +++ b/packages/@n8n/nodes-langchain/credentials/AzureEntraCognitiveServicesOAuth2Api.credentials.ts @@ -10,7 +10,7 @@ export class AzureEntraCognitiveServicesOAuth2Api implements ICredentialType { extends = ['oAuth2Api']; - documentationUrl = 'azureEntraCognitiveServicesOAuth2Api'; + documentationUrl = 'azureentracognitiveservicesoauth2api'; properties: INodeProperties[] = [ { diff --git a/packages/@n8n/nodes-langchain/eslint.config.mjs b/packages/@n8n/nodes-langchain/eslint.config.mjs index 983d4876af2..165607ee6cb 100644 --- a/packages/@n8n/nodes-langchain/eslint.config.mjs +++ b/packages/@n8n/nodes-langchain/eslint.config.mjs @@ -1,10 +1,14 @@ import { defineConfig } from 'eslint/config'; import { nodeConfig } from '@n8n/eslint-config/node'; import nodesBasePlugin from 'eslint-plugin-n8n-nodes-base'; +import { n8nCommunityNodesPlugin } from '@n8n/eslint-plugin-community-nodes'; export default defineConfig( nodeConfig, { + plugins: { + '@n8n/community-nodes': n8nCommunityNodesPlugin, + }, rules: { // TODO: remove all the following rules eqeqeq: 'warn', @@ -21,6 +25,8 @@ export default defineConfig( 'n8n-local-rules/no-argument-spread': 'warn', // TODO: mark error + '@n8n/community-nodes/credential-documentation-url': ['error', { allowSlugs: true }], + '@typescript-eslint/no-unnecessary-type-assertion': 'warn', '@typescript-eslint/naming-convention': ['error', { selector: 'memberLike', format: null }], '@typescript-eslint/no-explicit-any': 'warn', //812 warnings, better to fix in separate PR diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index 24da547ae50..45c83627fc5 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -28,8 +28,7 @@ export class Agent extends VersionedNodeType { ], }, }, - // Keep 2.2 until blocking bugs are fixed - defaultVersion: 2.2, + defaultVersion: 3, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/execute.ts index 679780208db..86fb29fa101 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/execute.ts @@ -275,8 +275,14 @@ function buildSteps( if (response) { const responses = response?.actionResponses ?? []; + + if (response.metadata?.previousRequests) { + steps.push(...response.metadata.previousRequests); + } + for (const tool of responses) { if (tool.action?.metadata?.itemIndex !== itemIndex) continue; + const toolInput: IDataObject = { ...tool.action.input, id: tool.action.id, @@ -386,7 +392,7 @@ export async function toolsAgentExecute( } const outputParser = await getOptionalOutputParser(this, itemIndex); const tools = await getTools(this, outputParser); - const options = this.getNodeParameter('options', itemIndex, { enableStreaming: true }) as { + const options = this.getNodeParameter('options', itemIndex) as { systemMessage?: string; maxIterations?: number; returnIntermediateSteps?: boolean; @@ -394,6 +400,10 @@ export async function toolsAgentExecute( enableStreaming?: boolean; }; + if (options.enableStreaming === undefined) { + options.enableStreaming = true; + } + // Prepare the prompt messages and prompt template. const messages = await prepareMessages(this, itemIndex, { systemMessage: options.systemMessage, @@ -476,16 +486,16 @@ export async function toolsAgentExecute( const memoryVariables = await memory.loadMemoryVariables({}); chatHistory = memoryVariables['chat_history']; } - const response = await executor.invoke({ + const modelResponse = await executor.invoke({ ...invokeParams, chat_history: chatHistory, }); - if ('returnValues' in response) { + if ('returnValues' in modelResponse) { // Save conversation to memory including any tool call context - if (memory && input && response.returnValues.output) { + if (memory && input && modelResponse.returnValues.output) { // If there were tool calls in this conversation, include them in the context - let fullOutput = response.returnValues.output as string; + let fullOutput = modelResponse.returnValues.output as string; if (steps.length > 0) { // Include tool call information in the conversation context @@ -501,7 +511,7 @@ export async function toolsAgentExecute( await memory.saveContext({ input }, { output: fullOutput }); } // Include intermediate steps if requested - const result = { ...response.returnValues }; + const result = { ...modelResponse.returnValues }; if (options.returnIntermediateSteps && steps.length > 0) { result.intermediateSteps = steps; } @@ -509,7 +519,7 @@ export async function toolsAgentExecute( } // If response contains tool calls, we need to return this in the right format - const actions = createEngineRequests(this, response, itemIndex); + const actions = createEngineRequests(this, modelResponse, itemIndex); return { actions, diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts index fe960524284..03517284f92 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts @@ -1,6 +1,7 @@ import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; import OpenAI from 'openai'; +import { shouldIncludeModel } from '../../../vendors/OpenAi/helpers/modelFiltering'; import { getProxyAgent } from '@utils/httpProxyAgent'; export async function searchModels( @@ -22,25 +23,15 @@ export async function searchModels( }); const { data: models = [] } = await openai.models.list(); + const url = baseURL && new URL(baseURL); + const isCustomAPI = !!(url && url.hostname !== 'api.openai.com'); + const filteredModels = models.filter((model: { id: string }) => { - const url = baseURL && new URL(baseURL); - const isCustomAPI = url && url.hostname !== 'api.openai.com'; - // Filter out TTS, embedding, image generation, and other models - const isInvalidModel = - !isCustomAPI && - (model.id.startsWith('babbage') || - model.id.startsWith('davinci') || - model.id.startsWith('computer-use') || - model.id.startsWith('dall-e') || - model.id.startsWith('text-embedding') || - model.id.startsWith('tts') || - model.id.startsWith('whisper') || - model.id.startsWith('omni-moderation') || - (model.id.startsWith('gpt-') && model.id.includes('instruct'))); + const includeModel = shouldIncludeModel(model.id, isCustomAPI); - if (!filter) return !isInvalidModel; + if (!filter) return includeModel; - return !isInvalidModel && model.id.toLowerCase().includes(filter.toLowerCase()); + return includeModel && model.id.toLowerCase().includes(filter.toLowerCase()); }); filteredModels.sort((a, b) => a.id.localeCompare(b.id)); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.test.ts new file mode 100644 index 00000000000..f2cb3d5e632 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.test.ts @@ -0,0 +1,524 @@ +import { mock } from 'jest-mock-extended'; +import { NodeOperationError, type ILoadOptionsFunctions } from 'n8n-workflow'; + +// Mock external modules that are not needed for these unit tests +jest.mock('@langchain/redis', () => { + const state: any = { ctorArgs: undefined }; + class RedisVectorStore { + static fromDocuments = jest.fn(); + constructor(...args: any[]) { + state.ctorArgs = args; + } + } + return { RedisVectorStore, __state: state }; +}); +jest.mock('@utils/sharedFields', () => ({ metadataFilterField: {} }), { virtual: true }); +jest.mock( + '@utils/helpers', + () => ({ getMetadataFiltersValues: jest.fn(), logAiEvent: jest.fn() }), + { virtual: true }, +); +jest.mock('@utils/N8nBinaryLoader', () => ({ N8nBinaryLoader: class {} }), { virtual: true }); +jest.mock('@utils/N8nJsonLoader', () => ({ N8nJsonLoader: class {} }), { virtual: true }); +jest.mock('@utils/logWrapper', () => ({ logWrapper: (fn: any) => fn }), { virtual: true }); +// Mock the vector store node factory to avoid deep imports but preserve passed methods +jest.mock('../shared/createVectorStoreNode/createVectorStoreNode', () => ({ + createVectorStoreNode: (config: any) => + class BaseNode { + async getVectorStoreClient(...args: any[]) { + return config.getVectorStoreClient.apply(config, args); + } + async populateVectorStore(...args: any[]) { + return config.populateVectorStore.apply(config, args); + } + }, +})); +jest.mock('redis', () => ({ createClient: jest.fn() })); + +import { createClient } from 'redis'; + +import * as RedisNode from './VectorStoreRedis.node'; + +const MockCreateClient = createClient as jest.MockedFunction; + +describe('VectorStoreRedis.node', () => { + const helpers = mock(); + const loadOptionsFunctions = mock({ helpers }); + loadOptionsFunctions.logger = { + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + verbose: jest.fn(), + } as any; + + const baseCredentials = { + host: 'localhost', + port: 6379, + ssl: false, + user: 'default', + password: 'pass', + database: 0, + } as any; + + beforeEach(() => { + jest.resetAllMocks(); + // Reset cached client + RedisNode.redisConfig.client = null as any; + RedisNode.redisConfig.connectionString = ''; + }); + + describe('getRedisClient', () => { + it('creates and reuses client for same configuration', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + quit: jest.fn().mockResolvedValue(undefined), + } as any; + + MockCreateClient.mockReturnValue(mockClient); + + const context = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + } as any; + + const client1 = await RedisNode.getRedisClient(context); + const client2 = await RedisNode.getRedisClient(context); + + expect(MockCreateClient).toHaveBeenCalledTimes(1); + expect(mockClient.connect).toHaveBeenCalledTimes(1); + expect(mockClient.disconnect).not.toHaveBeenCalled(); + expect(client1).toBe(mockClient); + expect(client2).toBe(mockClient); + }); + + it('disconnects previous client and creates a new one when configuration changes', async () => { + const mockClient1 = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + quit: jest.fn().mockResolvedValue(undefined), + } as any; + const mockClient2 = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + quit: jest.fn().mockResolvedValue(undefined), + } as any; + + MockCreateClient.mockImplementationOnce(() => mockClient1).mockImplementationOnce( + () => mockClient2, + ); + + const context = { + getCredentials: jest + .fn() + .mockResolvedValueOnce(baseCredentials) + .mockResolvedValueOnce({ ...baseCredentials, port: 6380 }), + } as any; + + const client1 = await RedisNode.getRedisClient(context); + const client2 = await RedisNode.getRedisClient(context); + + expect(MockCreateClient).toHaveBeenCalledTimes(2); + expect(mockClient1.disconnect).toHaveBeenCalledTimes(1); + expect(mockClient2.connect).toHaveBeenCalledTimes(1); + expect(client1).toBe(mockClient1); + expect(client2).toBe(mockClient2); + }); + }); + + describe('listIndexes', () => { + it('returns mapped indexes when FT._LIST succeeds', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { _list: jest.fn().mockResolvedValue(['Idx1', 'Idx2']) }, + } as any; + + MockCreateClient.mockReturnValue(mockClient); + + (loadOptionsFunctions as any).getCredentials = jest.fn().mockResolvedValue(baseCredentials); + + const results = await (RedisNode.listIndexes as any).call(loadOptionsFunctions as any); + + expect(mockClient.ft._list).toHaveBeenCalled(); + expect(results).toEqual({ + results: [ + { name: 'Idx1', value: 'Idx1' }, + { name: 'Idx2', value: 'Idx2' }, + ], + }); + }); + + it('returns empty results when FT._LIST fails', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { _list: jest.fn().mockRejectedValue(new Error('no module')) }, + } as any; + + MockCreateClient.mockReturnValue(mockClient); + + const failureCredentials = { ...baseCredentials, port: 6380 }; + (loadOptionsFunctions as any).getCredentials = jest + .fn() + .mockResolvedValue(failureCredentials); + + const results = await (RedisNode.listIndexes as any).call(loadOptionsFunctions as any); + + expect(results).toEqual({ results: [] }); + }); + + it('returns empty results when FT._LIST returns unexpected data type', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { _list: jest.fn().mockResolvedValue({ unexpected: 'object' }) }, + } as any; + + MockCreateClient.mockReturnValue(mockClient); + + (loadOptionsFunctions as any).getCredentials = jest.fn().mockResolvedValue(baseCredentials); + + const results = await (RedisNode.listIndexes as any).call(loadOptionsFunctions as any); + + expect(results).toEqual({ results: [] }); + expect(loadOptionsFunctions.logger.warn).toHaveBeenCalledWith( + 'FT._LIST returned unexpected data type', + ); + }); + }); + + describe('getVectorStoreClient', () => { + it('constructs ExtendedRedisVectorSearch with correct options and passes filter tokens', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + sendCommand: jest + .fn() + .mockImplementation(async ([cmd]) => + cmd === 'FT.INFO' ? await Promise.resolve(undefined) : await Promise.resolve([]), + ), + } as any; + + // Adapt to new client.ft.info usage + mockClient.ft = { ...(mockClient.ft || {}), info: jest.fn().mockResolvedValue(undefined) }; + + (MockCreateClient as any).mockReturnValue(mockClient); + + // Provide a base class method that ExtendedRedisVectorSearch will call via super + const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis'); + RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest + .fn() + .mockResolvedValue('ok'); + + const context: any = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + getNodeParameter: (name: string) => { + const map: Record = { + redisIndex: 'myIndex', + 'options.keyPrefix': 'doc', + 'options.metadataKey': 'm', + 'options.contentKey': 'c', + 'options.vectorKey': 'v', + 'options.metadataFilter': 'a,b', + }; + return map[name]; + }, + getNode: () => ({ name: 'VectorStoreRedis' }), + logger: loadOptionsFunctions.logger, + } as any; + + const embeddings: any = {}; + const instance = new RedisNode.VectorStoreRedis(); + const client = await (instance as any).getVectorStoreClient( + context, + undefined, + embeddings, + 0, + ); + + // Ensure FT.INFO is called to validate index + expect(mockClient.ft.info).toHaveBeenCalledWith('myIndex'); + + // The base class constructor should have been called with embeddings and options + const state = RedisVectorStoreMod.__state; + expect(state.ctorArgs[0]).toBe(embeddings); + expect(state.ctorArgs[1]).toMatchObject({ + redisClient: mockClient, + indexName: 'myIndex', + keyPrefix: 'doc', + metadataKey: 'm', + contentKey: 'c', + vectorKey: 'v', + }); + + // Call the overridden method and ensure behavior is as expected + const res = await client.similaritySearchVectorWithScore([1, 2], 3); + expect(res).toBe('ok'); + // Validate filter tokens got captured on the instance + expect(client.defaultFilter).toEqual(['a', 'b']); + }); + + it('trims and removes empty metadata filter tokens', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { info: jest.fn().mockResolvedValue(undefined) }, + } as any; + + (MockCreateClient as any).mockReturnValue(mockClient); + + const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis'); + RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest + .fn() + .mockResolvedValue('ok'); + + const context: any = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + getNodeParameter: (name: string) => { + const map: Record = { + redisIndex: 'idx2', + 'options.keyPrefix': '', + 'options.metadataKey': '', + 'options.contentKey': '', + 'options.vectorKey': '', + 'options.metadataFilter': 'tag1, tag2 , ,tag3', + }; + return map[name]; + }, + getNode: () => ({ name: 'VectorStoreRedis' }), + logger: loadOptionsFunctions.logger, + } as any; + + const node = new RedisNode.VectorStoreRedis(); + const client = await (node as any).getVectorStoreClient(context, undefined, {}, 0); + + // Ensure trimming/removal works + expect(client.defaultFilter).toEqual(['tag1', 'tag2', 'tag3']); + }); + + it('omits optional keys when empty/whitespace and handles empty filter as null', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { info: jest.fn().mockResolvedValue(undefined) }, + } as any; + + (MockCreateClient as any).mockReturnValue(mockClient); + + const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis'); + RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest + .fn() + .mockResolvedValue('ok'); + + const context: any = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + getNodeParameter: (name: string) => { + const map: Record = { + redisIndex: 'myIndex', + 'options.keyPrefix': ' ', + 'options.metadataKey': ' ', + 'options.contentKey': '', + 'options.vectorKey': ' \t', + 'options.metadataFilter': '', + }; + return map[name]; + }, + getNode: () => ({ name: 'VectorStoreRedis' }), + logger: loadOptionsFunctions.logger, + } as any; + + const embeddings: any = {}; + const node = new RedisNode.VectorStoreRedis(); + const instance = await (node as any).getVectorStoreClient(context, undefined, embeddings, 0); + + // Ensure FT.INFO is called to validate index + expect(mockClient.ft.info).toHaveBeenCalledWith('myIndex'); + + const opts = RedisVectorStoreMod.__state.ctorArgs[1]; + expect(opts).toMatchObject({ redisClient: mockClient, indexName: 'myIndex' }); + expect(opts).not.toHaveProperty('keyPrefix'); + expect(opts).not.toHaveProperty('metadataKey'); + expect(opts).not.toHaveProperty('contentKey'); + expect(opts).not.toHaveProperty('vectorKey'); + + const res = await instance.similaritySearchVectorWithScore([0], 1); + expect(res).toBeDefined(); + expect(instance.defaultFilter).toBeUndefined(); + }); + + it('returns undefined filter when filter string contains only whitespace and commas', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { info: jest.fn().mockResolvedValue(undefined) }, + } as any; + + (MockCreateClient as any).mockReturnValue(mockClient); + + const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis'); + RedisVectorStoreMod.RedisVectorStore.prototype.similaritySearchVectorWithScore = jest + .fn() + .mockResolvedValue('ok'); + + const context: any = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + getNodeParameter: (name: string) => { + const map: Record = { + redisIndex: 'myIndex', + 'options.keyPrefix': '', + 'options.metadataKey': '', + 'options.contentKey': '', + 'options.vectorKey': '', + 'options.metadataFilter': ' , , , ', + }; + return map[name]; + }, + getNode: () => ({ name: 'VectorStoreRedis' }), + logger: loadOptionsFunctions.logger, + } as any; + + const node = new RedisNode.VectorStoreRedis(); + const instance = await (node as any).getVectorStoreClient(context, undefined, {}, 0); + + // Filter with only whitespace and commas should result in undefined + expect(instance.defaultFilter).toBeUndefined(); + }); + + it('throws NodeOperationError when index is missing', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { info: jest.fn().mockRejectedValue(new Error('no such index')) }, + } as any; + (MockCreateClient as any).mockReturnValue(mockClient); + + const context: any = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + getNodeParameter: (name: string) => (name === 'redisIndex' ? 'idx' : ''), + getNode: () => ({ name: 'VectorStoreRedis' }), + }; + + const node = new RedisNode.VectorStoreRedis(); + await expect((node as any).getVectorStoreClient(context, undefined, {}, 0)).rejects.toEqual( + new NodeOperationError(context.getNode(), 'Index idx not found', { + itemIndex: 0, + description: 'Please check that the index exists in your Redis instance', + }), + ); + }); + }); + + describe('populateVectorStore', () => { + it('drops index and deletes the documents when overwrite is true; passes TTL and batch size', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + ft: { dropIndex: jest.fn().mockResolvedValue(undefined) }, + } as any; + (MockCreateClient as any).mockReturnValue(mockClient); + + const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis'); + RedisVectorStoreMod.RedisVectorStore.fromDocuments = jest.fn().mockResolvedValue(undefined); + + const context: any = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + getNodeParameter: (name: string) => { + const map: Record = { + redisIndex: 'myIndex', + 'options.overwriteDocuments': true, + 'options.keyPrefix': 'doc', + 'options.metadataKey': 'm', + 'options.contentKey': 'c', + 'options.vectorKey': 'v', + 'options.ttl': 60, + embeddingBatchSize: 123, + }; + return map[name]; + }, + getNode: () => ({ name: 'VectorStoreRedis' }), + logger: loadOptionsFunctions.logger, + } as any; + + const node = new RedisNode.VectorStoreRedis(); + await (node as any).populateVectorStore( + context, + {}, + [{ pageContent: 'hello', metadata: {} }], + 0, + ); + + expect(mockClient.ft.dropIndex).toHaveBeenCalledWith('myIndex', { DD: true }); + + expect(RedisVectorStoreMod.RedisVectorStore.fromDocuments).toHaveBeenCalledWith( + [{ pageContent: 'hello', metadata: {} }], + {}, + { + redisClient: mockClient, + indexName: 'myIndex', + keyPrefix: 'doc', + metadataKey: 'm', + contentKey: 'c', + vectorKey: 'v', + ttl: 60, + }, + ); + }); + + it('logs and throws NodeOperationError on failure', async () => { + const mockClient = { + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + quit: jest.fn(), + sendCommand: jest.fn().mockResolvedValue(undefined), + } as any; + (MockCreateClient as any).mockReturnValue(mockClient); + + const RedisVectorStoreMod: any = jest.requireMock('@langchain/redis'); + RedisVectorStoreMod.RedisVectorStore.fromDocuments = jest + .fn() + .mockRejectedValue(new Error('fail')); + + const context: any = { + getCredentials: jest.fn().mockResolvedValue(baseCredentials), + getNodeParameter: (name: string) => (name === 'redisIndex' ? 'idx' : ''), + getNode: () => ({ name: 'VectorStoreRedis' }), + logger: loadOptionsFunctions.logger, + } as any; + + const node = new RedisNode.VectorStoreRedis(); + await expect((node as any).populateVectorStore(context, {}, [], 0)).rejects.toEqual( + new NodeOperationError(context.getNode(), 'Error: fail', { + itemIndex: 0, + description: 'Please check your index/schema and parameters', + }), + ); + + expect(loadOptionsFunctions.logger.info).toHaveBeenCalledWith( + 'Error while populating the store: fail', + ); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.ts new file mode 100644 index 00000000000..6b4b2e1f133 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.ts @@ -0,0 +1,412 @@ +import type { EmbeddingsInterface } from '@langchain/core/embeddings'; +import { RedisVectorStore } from '@langchain/redis'; +import type { RedisVectorStoreConfig } from '@langchain/redis/dist/vectorstores'; +import { + type IExecuteFunctions, + type ILoadOptionsFunctions, + type INodeProperties, + type ISupplyDataFunctions, + NodeOperationError, +} from 'n8n-workflow'; +import type { RedisClientOptions } from 'redis'; +import { createClient } from 'redis'; + +import { createVectorStoreNode } from '../shared/createVectorStoreNode/createVectorStoreNode'; + +/** + * Constants for the name of the credentials and Node parameters. + */ +const REDIS_CREDENTIALS = 'redis'; +const REDIS_INDEX_NAME = 'redisIndex'; +const REDIS_KEY_PREFIX = 'keyPrefix'; +const REDIS_OVERWRITE_DOCUMENTS = 'overwriteDocuments'; +const REDIS_METADATA_KEY = 'metadataKey'; +const REDIS_METADATA_FILTER = 'metadataFilter'; +const REDIS_CONTENT_KEY = 'contentKey'; +const REDIS_EMBEDDING_KEY = 'vectorKey'; +const REDIS_TTL = 'ttl'; + +const redisIndexRLC: INodeProperties = { + displayName: 'Redis Index', + name: REDIS_INDEX_NAME, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'redisIndexSearch', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + }, + ], +}; + +const metadataFilterField: INodeProperties = { + displayName: 'Metadata Filter', + name: REDIS_METADATA_FILTER, + type: 'string', + description: + 'The comma-separated list of words by which to apply additional full-text metadata filtering', + placeholder: 'Item1,Item2,Item3', + default: '', +}; + +const metadataKeyField: INodeProperties = { + displayName: 'Metadata Key', + name: REDIS_METADATA_KEY, + type: 'string', + description: 'The hash key to be used to store the metadata of the document', + placeholder: 'metadata', + default: '', +}; + +const contentKeyField: INodeProperties = { + displayName: 'Content Key', + name: REDIS_CONTENT_KEY, + type: 'string', + description: 'The hash key to be used to store the content of the document', + placeholder: 'content', + default: '', +}; + +const embeddingKeyField: INodeProperties = { + displayName: 'Embedding Key', + name: REDIS_EMBEDDING_KEY, + type: 'string', + description: 'The hash key to be used to store the embedding of the document', + placeholder: 'content_vector', + default: '', +}; + +const overwriteDocuments: INodeProperties = { + displayName: 'Overwrite Documents', + name: REDIS_OVERWRITE_DOCUMENTS, + type: 'boolean', + description: 'Whether existing documents and the index should be overwritten', + default: false, +}; + +const keyPrefixField: INodeProperties = { + displayName: 'Key Prefix', + name: REDIS_KEY_PREFIX, + type: 'string', + description: 'Prefix for Redis keys storing the documents', + placeholder: 'doc', + default: '', +}; + +const ttlField: INodeProperties = { + displayName: 'Time-To-Live', + name: REDIS_TTL, + description: 'Time-to-live for the documents in seconds', + placeholder: '0', + type: 'number', + default: '', +}; + +const sharedFields: INodeProperties[] = [redisIndexRLC]; + +const insertFields: INodeProperties[] = [ + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + keyPrefixField, + overwriteDocuments, + metadataKeyField, + contentKeyField, + embeddingKeyField, + ttlField, + ], + }, +]; + +const retrieveFields: INodeProperties[] = [ + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + metadataFilterField, + keyPrefixField, + metadataKeyField, + contentKeyField, + embeddingKeyField, + ], + }, +]; + +export const redisConfig = { + client: null as ReturnType | null, + connectionString: '', +}; + +/** + * Type used for cleaner, more intentional typing. + */ +type IFunctionsContext = IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions; + +/** + * Get the Redis client. + * @param context - The context. + * @returns the Redis client for the node. + */ +export async function getRedisClient(context: IFunctionsContext) { + const credentials = await context.getCredentials(REDIS_CREDENTIALS); + + // Create client configuration object + const config: RedisClientOptions = { + socket: { + host: (credentials.host as string) || 'localhost', + port: (credentials.port as number) || 6379, + tls: credentials.ssl === true, + }, + username: credentials.user as string, + password: credentials.password as string, + database: credentials.database as number, + clientInfoTag: 'n8n', + }; + + if (!redisConfig.client || redisConfig.connectionString !== JSON.stringify(config)) { + if (redisConfig.client) { + await redisConfig.client.disconnect(); + } + + redisConfig.connectionString = JSON.stringify(config); + redisConfig.client = createClient(config); + + if (redisConfig.client) { + redisConfig.client.on('error', (error: Error) => { + context.logger.error(`[Redis client] ${error.message}`, { error }); + }); + + await redisConfig.client.connect(); + } + } + + return redisConfig.client; +} + +/** + * Type guard to check if a value is a string array. + * @param value - The value to check. + * @returns True if the value is a string array, false otherwise. + */ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +/** + * Get the complete list of indexes from Redis. + * @returns The list of indexes. + */ +export async function listIndexes(this: ILoadOptionsFunctions) { + const client = await getRedisClient(this); + + if (client === null) { + return { results: [] }; + } + + try { + // Get all indexes using FT._LIST command + const indexes = await client.ft._list(); + + // Validate that indexes is actually a string array + if (!isStringArray(indexes)) { + this.logger.warn('FT._LIST returned unexpected data type'); + return { results: [] }; + } + + const results = indexes.map((index) => ({ + name: index, + value: index, + })); + + return { results }; + } catch (error) { + this.logger.info('Failed to get Redis indexes: ' + error.message); + return { results: [] }; + } +} + +/** + * Get a parameter from the context. + * @param key - The key of the parameter. + * @param context - The context. + * @param itemIndex - The index. + * @returns The value. + */ +export function getParameter(key: string, context: IFunctionsContext, itemIndex: number): string { + return context.getNodeParameter(key, itemIndex, '', { + extractValue: true, + }) as string; +} + +/** + * Get a parameter from the context as a number. + * @param key - The key of the parameter. + * @param context - The context. + * @param itemIndex - The index. + * @returns The value. + */ +export function getParameterAsNumber( + key: string, + context: IFunctionsContext, + itemIndex: number, +): number { + return context.getNodeParameter(key, itemIndex, '', { + extractValue: true, + }) as number; +} + +/** + * Extended RedisVectorStore class to handle custom filtering. + * + * This wrapper is necessary because when used as a retriever, the similaritySearchVectorWithScore should + * use a processed filter + */ +class ExtendedRedisVectorSearch extends RedisVectorStore { + defaultFilter?: string[]; + + constructor(embeddings: EmbeddingsInterface, options: RedisVectorStoreConfig, filter?: string[]) { + super(embeddings, options); + this.defaultFilter = filter; + } + + async similaritySearchVectorWithScore(query: number[], k: number) { + return await super.similaritySearchVectorWithScore(query, k, this.defaultFilter); + } +} + +const getIndexName = getParameter.bind(null, REDIS_INDEX_NAME); +const getKeyPrefix = getParameter.bind(null, `options.${REDIS_KEY_PREFIX}`); +const getOverwrite = getParameter.bind(null, `options.${REDIS_OVERWRITE_DOCUMENTS}`); +const getContentKey = getParameter.bind(null, `options.${REDIS_CONTENT_KEY}`); +const getMetadataFilter = getParameter.bind(null, `options.${REDIS_METADATA_FILTER}`); +const getMetadataKey = getParameter.bind(null, `options.${REDIS_METADATA_KEY}`); +const getEmbeddingKey = getParameter.bind(null, `options.${REDIS_EMBEDDING_KEY}`); +const getTtl = getParameterAsNumber.bind(null, `options.${REDIS_TTL}`); + +export class VectorStoreRedis extends createVectorStoreNode({ + meta: { + displayName: 'Redis Vector Store', + name: 'vectorStoreRedis', + description: 'Work with your data in a Redis vector index', + icon: { light: 'file:redis.svg', dark: 'file:redis.dark.svg' }, + docsUrl: + 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.vectorstoreredis/', + credentials: [ + { + name: REDIS_CREDENTIALS, + required: true, + }, + ], + operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'], + }, + methods: { listSearch: { redisIndexSearch: listIndexes } }, + retrieveFields, + loadFields: retrieveFields, + insertFields, + sharedFields, + async getVectorStoreClient(context, _filter, embeddings, itemIndex) { + const client = await getRedisClient(context); + const indexField = getIndexName(context, itemIndex).trim(); + const keyPrefixField = getKeyPrefix(context, itemIndex).trim(); + const metadataField = getMetadataKey(context, itemIndex).trim(); + const contentField = getContentKey(context, itemIndex).trim(); + const embeddingField = getEmbeddingKey(context, itemIndex).trim(); + const filter = getMetadataFilter(context, itemIndex).trim(); + + if (client === null) { + throw new NodeOperationError(context.getNode(), 'Redis client not initialized', { + itemIndex, + description: 'Please check your Redis connection details', + }); + } + + // Check if index exists by trying to get info about it + try { + await client.ft.info(indexField); + } catch (error) { + throw new NodeOperationError(context.getNode(), `Index ${indexField} not found`, { + itemIndex, + description: 'Please check that the index exists in your Redis instance', + }); + } + + // Process filter: split by comma, trim, and remove empty strings + // If no valid filter terms exist, pass undefined instead of empty array + const filterTerms = filter + ? filter + .split(',') + .map((s) => s.trim()) + .filter((s) => s) + : []; + + return new ExtendedRedisVectorSearch( + embeddings, + { + redisClient: client, + indexName: indexField, + ...(keyPrefixField ? { keyPrefix: keyPrefixField } : {}), + ...(metadataField ? { metadataKey: metadataField } : {}), + ...(contentField ? { contentKey: contentField } : {}), + ...(embeddingField ? { vectorKey: embeddingField } : {}), + }, + filterTerms.length > 0 ? filterTerms : undefined, + ); + }, + async populateVectorStore(context, embeddings, documents, itemIndex) { + const client = await getRedisClient(context); + + if (client === null) { + throw new NodeOperationError(context.getNode(), 'Redis client not initialized', { + itemIndex, + description: 'Please check your Redis connection details', + }); + } + + try { + const indexField = getIndexName(context, itemIndex).trim(); + const overwrite = getOverwrite(context, itemIndex); + const keyPrefixField = getKeyPrefix(context, itemIndex).trim(); + const metadataField = getMetadataKey(context, itemIndex).trim(); + const contentField = getContentKey(context, itemIndex).trim(); + const embeddingField = getEmbeddingKey(context, itemIndex).trim(); + const ttl = getTtl(context, itemIndex); + + if (overwrite) { + await client.ft.dropIndex(indexField, { DD: true }); + } + + await ExtendedRedisVectorSearch.fromDocuments(documents, embeddings, { + redisClient: client, + indexName: indexField, + ...(keyPrefixField ? { keyPrefix: keyPrefixField } : {}), + ...(metadataField ? { metadataKey: metadataField } : {}), + ...(contentField ? { contentKey: contentField } : {}), + ...(embeddingField ? { vectorKey: embeddingField } : {}), + ...(ttl ? { ttl } : {}), + }); + } catch (error) { + context.logger.info(`Error while populating the store: ${error.message}`); + throw new NodeOperationError(context.getNode(), `Error: ${error.message}`, { + itemIndex, + description: 'Please check your index/schema and parameters', + }); + } + }, +}) {} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/redis.dark.svg b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/redis.dark.svg new file mode 100644 index 00000000000..c528cbc1ca3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/redis.dark.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + ]> + + + + + + + + + + diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/redis.svg b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/redis.svg new file mode 100644 index 00000000000..cdab8ae5095 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/redis.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + ]> + + + + + + + + + + diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.test.ts index 40fc1d494d1..43bfd892ee8 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.test.ts @@ -5,7 +5,12 @@ import type { Embeddings } from '@langchain/core/embeddings'; import type { VectorStore } from '@langchain/core/vectorstores'; import { mock } from 'jest-mock-extended'; import type { DynamicTool } from 'langchain/tools'; -import type { ISupplyDataFunctions, NodeParameterValueType } from 'n8n-workflow'; +import type { + IExecuteFunctions, + ISupplyDataFunctions, + NodeParameterValueType, + INodeExecutionData, +} from 'n8n-workflow'; import { createVectorStoreNode } from './createVectorStoreNode'; import type { VectorStoreNodeConstructorArgs } from './types'; @@ -16,6 +21,7 @@ jest.mock('@utils/logWrapper', () => ({ const DEFAULT_PARAMETERS = { options: {}, + useReranker: false, topK: 1, }; @@ -222,4 +228,177 @@ describe('createVectorStoreNode', () => { ]); }); }); + + describe('execute mode', () => { + const executeContext = mock({ + getNodeParameter: jest.fn(), + getInputConnectionData: jest.fn().mockReturnValue(embeddings), + getInputData: jest.fn(), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('retrieve-as-tool mode in execute context', () => { + it('should execute retrieve-as-tool and return documents with metadata', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve-as-tool', + includeDocumentMetadata: true, + }; + + const inputData: INodeExecutionData[] = [ + { + json: { input: MOCK_SEARCH_VALUE }, + pairedItem: { item: 0 }, + }, + ]; + + executeContext.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + executeContext.getInputData.mockReturnValue(inputData); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const result = await nodeType.execute.call(executeContext); + + // ASSERT + expect(result).toHaveLength(1); // One output array + expect(result[0][0]?.json?.response).toHaveLength(2); // Two documents returned + + expect(result[0][0]).toEqual({ + json: { + response: [ + { + type: 'text', + text: JSON.stringify({ + pageContent: 'first page', + metadata: { id: 123 }, + }), + }, + { + type: 'text', + text: JSON.stringify({ + pageContent: 'second page', + metadata: { id: 567 }, + }), + }, + ], + }, + pairedItem: { item: 0 }, + }); + + expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE); + expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + MOCK_EMBEDDED_SEARCH_VALUE, + parameters.topK, + undefined, // filter + ); + }); + + it('should execute retrieve-as-tool and return documents without metadata', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve-as-tool', + includeDocumentMetadata: false, + }; + + const inputData: INodeExecutionData[] = [ + { + json: { input: MOCK_SEARCH_VALUE }, + pairedItem: { item: 0 }, + }, + ]; + + executeContext.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + executeContext.getInputData.mockReturnValue(inputData); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const result = await nodeType.execute.call(executeContext); + + // ASSERT + expect(result[0][0].json.response).toHaveLength(2); + const response = result[0][0].json.response as Array<{ pageContent: string }>; + const doc0 = JSON.parse(response[0].pageContent); + const doc1 = JSON.parse(response[1].pageContent); + expect(doc0).not.toHaveProperty('metadata'); + expect(doc0).toEqual({ pageContent: 'first page' }); + expect(doc1).toEqual({ pageContent: 'second page' }); + }); + + it('should process multiple input items', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve-as-tool', + includeDocumentMetadata: true, + }; + + const inputData: INodeExecutionData[] = [ + { + json: { input: 'first query' }, + pairedItem: { item: 0 }, + }, + { + json: { input: 'second query' }, + pairedItem: { item: 1 }, + }, + ]; + + executeContext.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + executeContext.getInputData.mockReturnValue(inputData); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const result = await nodeType.execute.call(executeContext); + + // ASSERT + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); // One result item per input query + + // Check that embedQuery was called for both input queries + expect(embeddings.embedQuery).toHaveBeenCalledTimes(2); + expect(embeddings.embedQuery).toHaveBeenNthCalledWith(1, 'first query'); + expect(embeddings.embedQuery).toHaveBeenNthCalledWith(2, 'second query'); + + // Check pairedItem references and that each result contains both documents + expect(result[0][0].pairedItem).toEqual({ item: 0 }); + expect(result[0][0].json.response).toHaveLength(2); // 2 documents for first query + expect(result[0][1].pairedItem).toEqual({ item: 1 }); + expect(result[0][1].json.response).toHaveLength(2); // 2 documents for second query + }); + + it('should throw error for unsupported mode in execute', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve', // This mode is not supported in execute + }; + + executeContext.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + + // ACT & ASSERT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + + await expect(nodeType.execute.call(executeContext)).rejects.toThrow( + 'Only the "load", "update", "insert", and "retrieve-as-tool" operation modes are supported with execute', + ); + }); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts index 7fff59b5203..3cbd58bf91d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/createVectorStoreNode.ts @@ -20,6 +20,7 @@ import { handleUpdateOperation, handleRetrieveOperation, handleRetrieveAsToolOperation, + handleRetrieveAsToolExecuteOperation, } from './operations'; import type { NodeOperationMode, VectorStoreNodeConstructorArgs } from './types'; // Import utility functions @@ -291,9 +292,26 @@ export const createVectorStoreNode = ( return [resultData]; } + if (mode === 'retrieve-as-tool') { + const items = this.getInputData(0); + const resultData = []; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + const docs = await handleRetrieveAsToolExecuteOperation( + this, + args, + embeddings, + itemIndex, + ); + resultData.push(...docs); + } + + return [resultData]; + } + throw new NodeOperationError( this.getNode(), - 'Only the "load", "update" and "insert" operation modes are supported with execute', + 'Only the "load", "update", "insert", and "retrieve-as-tool" operation modes are supported with execute', ); } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/__tests__/retrieveAsToolExecuteOperation.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/__tests__/retrieveAsToolExecuteOperation.test.ts new file mode 100644 index 00000000000..608a6868b87 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/__tests__/retrieveAsToolExecuteOperation.test.ts @@ -0,0 +1,425 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { Document } from '@langchain/core/documents'; +import type { Embeddings } from '@langchain/core/embeddings'; +import type { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors'; +import type { VectorStore } from '@langchain/core/vectorstores'; +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { logAiEvent } from '@utils/helpers'; + +import type { VectorStoreNodeConstructorArgs } from '../../types'; +import { handleRetrieveAsToolExecuteOperation } from '../retrieveAsToolExecuteOperation'; + +// Mock helper functions from external modules +jest.mock('@utils/helpers', () => ({ + getMetadataFiltersValues: jest.fn().mockReturnValue({ testFilter: 'value' }), + logAiEvent: jest.fn(), +})); + +describe('handleRetrieveAsToolExecuteOperation', () => { + let mockContext: MockProxy; + let mockEmbeddings: MockProxy; + let mockVectorStore: MockProxy; + let mockReranker: MockProxy; + let mockArgs: VectorStoreNodeConstructorArgs; + let nodeParameters: Record; + let inputData: INodeExecutionData[]; + + beforeEach(() => { + nodeParameters = { + topK: 3, + includeDocumentMetadata: true, + useReranker: false, + }; + + inputData = [ + { + json: { input: 'test search query' }, + pairedItem: { item: 0 }, + }, + ]; + + mockContext = mock(); + mockContext.getNodeParameter.mockImplementation((parameterName, _itemIndex, fallbackValue) => { + if (typeof parameterName !== 'string') return fallbackValue; + return nodeParameters[parameterName] ?? fallbackValue; + }); + mockContext.getInputData.mockReturnValue(inputData); + + mockEmbeddings = mock(); + mockEmbeddings.embedQuery.mockResolvedValue([0.1, 0.2, 0.3]); + + mockVectorStore = mock(); + mockVectorStore.similaritySearchVectorWithScore.mockResolvedValue([ + [{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } } as Document, 0.95], + [{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85], + [{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } } as Document, 0.75], + ]); + + mockReranker = mock(); + mockReranker.compressDocuments.mockResolvedValue([ + { + pageContent: 'test content 2', + metadata: { test: 'metadata 2', relevanceScore: 0.98 }, + } as Document, + { + pageContent: 'test content 1', + metadata: { test: 'metadata 1', relevanceScore: 0.92 }, + } as Document, + { + pageContent: 'test content 3', + metadata: { test: 'metadata 3', relevanceScore: 0.88 }, + } as Document, + ]); + + mockContext.getInputConnectionData.mockResolvedValue(mockReranker); + + mockArgs = { + meta: { + displayName: 'Test Vector Store', + name: 'testVectorStore', + description: 'Vector store for testing', + docsUrl: 'https://example.com', + icon: 'file:testIcon.svg', + }, + sharedFields: [], + getVectorStoreClient: jest.fn().mockResolvedValue(mockVectorStore), + populateVectorStore: jest.fn().mockResolvedValue(undefined), + releaseVectorStoreClient: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should retrieve documents from vector store using query from input data', async () => { + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(mockArgs.getVectorStoreClient).toHaveBeenCalledWith( + mockContext, + undefined, + mockEmbeddings, + 0, + ); + + expect(mockEmbeddings.embedQuery).toHaveBeenCalledWith('test search query'); + + expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + [0.1, 0.2, 0.3], + 3, + { testFilter: 'value' }, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(3); + const response = result[0].json.response as Array<{ type: string; text: string }>; + expect(response[0]).toEqual({ + type: 'text', + text: JSON.stringify({ + pageContent: 'test content 1', + metadata: { test: 'metadata 1' }, + }), + }); + expect(result[0].pairedItem).toEqual({ item: 0 }); + + expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore); + expect(logAiEvent).toHaveBeenCalledWith(mockContext, 'ai-vector-store-searched', { + input: 'test search query', + }); + }); + + it('should throw error when input data does not contain query', async () => { + inputData[0].json = { notQuery: 'some value' }; + + await expect( + handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0), + ).rejects.toThrow('Input data must contain a "input" field with the search query'); + }); + + it('should throw error when query is not a string', async () => { + inputData[0].json = { input: 123 }; + + await expect( + handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0), + ).rejects.toThrow('Input data must contain a "input" field with the search query'); + }); + + it('should throw error when query is empty string', async () => { + inputData[0].json = { input: '' }; + + await expect( + handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0), + ).rejects.toThrow('Input data must contain a "input" field with the search query'); + }); + + it('should include metadata when includeDocumentMetadata is true', async () => { + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(3); + const response = result[0].json.response as Array<{ type: string; text: string }>; + const firstDoc = JSON.parse(response[0].text); + expect(firstDoc).toHaveProperty('metadata'); + expect(firstDoc.metadata).toEqual({ test: 'metadata 1' }); + }); + + it('should exclude metadata when includeDocumentMetadata is false', async () => { + nodeParameters.includeDocumentMetadata = false; + + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(3); + const response = result[0].json.response as Array<{ pageContent: string }>; + const firstDoc = JSON.parse(response[0].pageContent); + expect(firstDoc).not.toHaveProperty('metadata'); + expect(firstDoc).toEqual({ + pageContent: 'test content 1', + }); + }); + + it('should limit results based on topK parameter', async () => { + nodeParameters.topK = 1; + + await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0); + + expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + expect.anything(), + 1, + expect.anything(), + ); + }); + + it('should use topK default value when not provided', async () => { + delete nodeParameters.topK; + + await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0); + + expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + expect.anything(), + 4, // default value + expect.anything(), + ); + }); + + it('should release vector store client even if search fails', async () => { + mockVectorStore.similaritySearchVectorWithScore.mockRejectedValueOnce( + new Error('Search failed'), + ); + + await expect( + handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0), + ).rejects.toThrow('Search failed'); + + expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore); + }); + + describe('reranking functionality', () => { + beforeEach(() => { + nodeParameters.useReranker = true; + }); + + it('should use reranker when useReranker is true', async () => { + await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0); + + expect(mockContext.getInputConnectionData).toHaveBeenCalledWith( + NodeConnectionTypes.AiReranker, + 0, + ); + expect(mockReranker.compressDocuments).toHaveBeenCalledWith( + [ + { pageContent: 'test content 1', metadata: { test: 'metadata 1' } }, + { pageContent: 'test content 2', metadata: { test: 'metadata 2' } }, + { pageContent: 'test content 3', metadata: { test: 'metadata 3' } }, + ], + 'test search query', + ); + }); + + it('should return reranked documents in the correct order', async () => { + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(3); + const response = result[0].json.response as Array<{ type: string; text: string }>; + + // First result should be the reranked first document (was second in original order) + const doc0 = JSON.parse(response[0].text); + expect(doc0.pageContent).toEqual('test content 2'); + expect(doc0.metadata).toEqual({ test: 'metadata 2' }); + + // Second result should be the reranked second document (was first in original order) + const doc1 = JSON.parse(response[1].text); + expect(doc1.pageContent).toEqual('test content 1'); + expect(doc1.metadata).toEqual({ test: 'metadata 1' }); + + // Third result should be the reranked third document + const doc2 = JSON.parse(response[2].text); + expect(doc2.pageContent).toEqual('test content 3'); + expect(doc2.metadata).toEqual({ test: 'metadata 3' }); + }); + + it('should handle reranking with includeDocumentMetadata false', async () => { + nodeParameters.includeDocumentMetadata = false; + + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(3); + const response = result[0].json.response as Array<{ pageContent: string }>; + + // Should maintain reranked order but exclude metadata + const doc0 = JSON.parse(response[0].pageContent); + expect(doc0).toEqual({ pageContent: 'test content 2' }); + const doc1 = JSON.parse(response[1].pageContent); + expect(doc1).toEqual({ pageContent: 'test content 1' }); + const doc2 = JSON.parse(response[2].pageContent); + expect(doc2).toEqual({ pageContent: 'test content 3' }); + }); + + it('should not call reranker when useReranker is false', async () => { + nodeParameters.useReranker = false; + + await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0); + + expect(mockContext.getInputConnectionData).not.toHaveBeenCalled(); + expect(mockReranker.compressDocuments).not.toHaveBeenCalled(); + }); + + it('should release vector store client even if reranking fails', async () => { + mockReranker.compressDocuments.mockRejectedValueOnce(new Error('Reranking failed')); + + await expect( + handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0), + ).rejects.toThrow('Reranking failed'); + + expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore); + }); + + it('should properly handle relevanceScore from reranker metadata', async () => { + // Mock reranker to return documents with relevanceScore in different metadata structure + mockReranker.compressDocuments.mockResolvedValueOnce([ + { + pageContent: 'test content 2', + metadata: { test: 'metadata 2', relevanceScore: 0.98, otherField: 'value' }, + } as Document, + { + pageContent: 'test content 1', + metadata: { test: 'metadata 1', relevanceScore: 0.92 }, + } as Document, + ]); + + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(2); + const response = result[0].json.response as Array<{ type: string; text: string }>; + + // Check that relevanceScore is used but metadata is preserved without relevanceScore + const doc0 = JSON.parse(response[0].text); + expect(doc0.metadata).toEqual({ test: 'metadata 2', otherField: 'value' }); + expect(doc0.metadata).not.toHaveProperty('relevanceScore'); + + const doc1 = JSON.parse(response[1].text); + expect(doc1.metadata).toEqual({ test: 'metadata 1' }); + expect(doc1.metadata).not.toHaveProperty('relevanceScore'); + }); + + it('should not use reranker when no documents are found', async () => { + mockVectorStore.similaritySearchVectorWithScore.mockResolvedValueOnce([]); + + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(mockContext.getInputConnectionData).not.toHaveBeenCalled(); + expect(mockReranker.compressDocuments).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(0); + }); + }); + + describe('empty result handling', () => { + it('should return empty array when vector store returns no documents', async () => { + mockVectorStore.similaritySearchVectorWithScore.mockResolvedValueOnce([]); + + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(0); + expect(logAiEvent).toHaveBeenCalledWith(mockContext, 'ai-vector-store-searched', { + input: 'test search query', + }); + }); + }); + + describe('error handling', () => { + it('should release client resources when embedQuery fails', async () => { + mockEmbeddings.embedQuery.mockRejectedValueOnce(new Error('Embedding failed')); + + await expect( + handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0), + ).rejects.toThrow('Embedding failed'); + + expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore); + }); + + it('should handle missing releaseVectorStoreClient function gracefully', async () => { + delete mockArgs.releaseVectorStoreClient; + + const result = await handleRetrieveAsToolExecuteOperation( + mockContext, + mockArgs, + mockEmbeddings, + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].json.response).toHaveLength(3); + // Should not throw error when releaseVectorStoreClient is undefined + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/index.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/index.ts index 74d2c6cd41a..e42f1897733 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/index.ts @@ -3,3 +3,4 @@ export * from './insertOperation'; export * from './updateOperation'; export * from './retrieveOperation'; export * from './retrieveAsToolOperation'; +export * from './retrieveAsToolExecuteOperation'; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/retrieveAsToolExecuteOperation.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/retrieveAsToolExecuteOperation.ts new file mode 100644 index 00000000000..84e37bc70a0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode/operations/retrieveAsToolExecuteOperation.ts @@ -0,0 +1,111 @@ +import type { Embeddings } from '@langchain/core/embeddings'; +import type { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors'; +import type { VectorStore } from '@langchain/core/vectorstores'; +import { + assertParamIsBoolean, + assertParamIsNumber, + NodeConnectionTypes, + type IExecuteFunctions, + type INodeExecutionData, +} from 'n8n-workflow'; + +import { getMetadataFiltersValues, logAiEvent } from '@utils/helpers'; + +import type { VectorStoreNodeConstructorArgs } from '../types'; + +/** + * Handles the 'retrieve-as-tool' operation mode in execute context + * Searches the vector store for documents similar to a query and returns execution data + * This is similar to the load operation but designed to work with the new tool execution pattern + */ +export async function handleRetrieveAsToolExecuteOperation( + context: IExecuteFunctions, + args: VectorStoreNodeConstructorArgs, + embeddings: Embeddings, + itemIndex: number, +): Promise { + const filter = getMetadataFiltersValues(context, itemIndex); + const vectorStore = await args.getVectorStoreClient( + context, + // We'll pass filter to similaritySearchVectorWithScore instead of getVectorStoreClient + undefined, + embeddings, + itemIndex, + ); + + try { + // Get the search parameters - query from input data, others from node parameters + const inputData = context.getInputData(); + const item = inputData[itemIndex]; + const query = typeof item.json.input === 'string' ? item.json.input : undefined; + + if (!query || typeof query !== 'string') { + throw new Error('Input data must contain a "input" field with the search query'); + } + + const topK = context.getNodeParameter('topK', itemIndex, 4); + assertParamIsNumber('topK', topK, context.getNode()); + const useReranker = context.getNodeParameter('useReranker', itemIndex, false); + assertParamIsBoolean('useReranker', useReranker, context.getNode()); + + const includeDocumentMetadata = context.getNodeParameter( + 'includeDocumentMetadata', + itemIndex, + true, + ); + assertParamIsBoolean('includeDocumentMetadata', includeDocumentMetadata, context.getNode()); + + // Embed the query to prepare for vector similarity search + const embeddedQuery = await embeddings.embedQuery(query); + + // Get the most similar documents to the embedded query + let docs = await vectorStore.similaritySearchVectorWithScore(embeddedQuery, topK, filter); + + // If reranker is used, rerank the documents + if (useReranker && docs.length > 0) { + const reranker = (await context.getInputConnectionData( + NodeConnectionTypes.AiReranker, + 0, + )) as BaseDocumentCompressor; + const documents = docs.map(([doc]) => doc); + + const rerankedDocuments = await reranker.compressDocuments(documents, query); + docs = rerankedDocuments.map((doc) => { + const { relevanceScore, ...metadata } = doc.metadata || {}; + return [{ ...doc, metadata }, relevanceScore ?? 0]; + }); + } + + // Format the documents for the output similar to the original tool format + const serializedDocs = docs.map(([doc]) => { + if (includeDocumentMetadata) { + return { + type: 'text', + text: JSON.stringify({ ...doc }), + }; + } else { + return { + type: 'text', + pageContent: JSON.stringify({ pageContent: doc.pageContent }), + }; + } + }); + + // Log the AI event for analytics + logAiEvent(context, 'ai-vector-store-searched', { input: query }); + + return [ + { + json: { + response: serializedDocs, + }, + pairedItem: { + item: itemIndex, + }, + }, + ]; + } finally { + // Release the vector store client if a release method was provided + args.releaseVectorStoreClient?.(vectorStore); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/Ollama.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/Ollama.node.test.ts new file mode 100644 index 00000000000..e3672edca0d --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/Ollama.node.test.ts @@ -0,0 +1,680 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { z } from 'zod'; + +import * as helpers from '@utils/helpers'; + +import * as image from './actions/image'; +import * as text from './actions/text'; +import * as transport from './transport'; +import type { OllamaChatResponse, OllamaMessage } from './helpers/interfaces'; + +describe('Ollama Node', () => { + const executeFunctionsMock = mockDeep(); + const apiRequestMock = jest.spyOn(transport, 'apiRequest'); + const getConnectedToolsMock = jest.spyOn(helpers, 'getConnectedTools'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('Text -> Message', () => { + it('should call the API with correct parameters for basic message', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llama3.2:latest'; + case 'messages.values': + return [{ role: 'user', content: 'Hello, world!' }]; + case 'simplify': + return true; + case 'options': + return { + system: 'You are a helpful assistant.', + temperature: 0.7, + top_p: 0.9, + top_k: 40, + num_predict: 1024, + }; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + getConnectedToolsMock.mockResolvedValue([]); + apiRequestMock.mockResolvedValue({ + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { role: 'assistant', content: 'Hello! How can I help you today?' }, + done: true, + } as OllamaChatResponse); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { content: 'Hello! How can I help you today?' }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', { + body: { + model: 'llama3.2:latest', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Hello, world!' }, + ], + stream: false, + tools: [], + options: { + temperature: 0.7, + top_p: 0.9, + top_k: 40, + num_predict: 1024, + }, + }, + }); + }); + + it('should return full response when simplify is false', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llama3.2:latest'; + case 'messages.values': + return [{ role: 'user', content: 'Test message' }]; + case 'simplify': + return false; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + getConnectedToolsMock.mockResolvedValue([]); + const mockResponse = { + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { role: 'assistant', content: 'Test response' }, + done: true, + total_duration: 5000000, + load_duration: 1000000, + eval_count: 10, + eval_duration: 2000000, + } as OllamaChatResponse; + apiRequestMock.mockResolvedValue(mockResponse); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: mockResponse, + pairedItem: { item: 0 }, + }, + ]); + }); + + it('should handle tool calls correctly', async () => { + const mockTool = { + name: 'calculator', + description: 'Performs calculations', + schema: z.object({ + expression: z.string().describe('Mathematical expression to evaluate'), + }), + invoke: jest.fn().mockResolvedValue({ result: 42 }), + }; + + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llama3.2:latest'; + case 'messages.values': + return [{ role: 'user', content: 'What is 6 * 7?' }]; + case 'simplify': + return true; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]); + // @ts-expect-error: Mocking a tool, we do not implement the full interface + getConnectedToolsMock.mockResolvedValue([mockTool]); + + apiRequestMock.mockResolvedValueOnce({ + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + function: { + name: 'calculator', + arguments: { expression: '6 * 7' }, + }, + }, + ], + }, + done: true, + } as OllamaChatResponse); + + apiRequestMock.mockResolvedValueOnce({ + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { role: 'assistant', content: 'The result is 42.' }, + done: true, + } as OllamaChatResponse); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { content: 'The result is 42.' }, + pairedItem: { item: 0 }, + }, + ]); + expect(mockTool.invoke).toHaveBeenCalledWith({ expression: '6 * 7' }); + expect(apiRequestMock).toHaveBeenCalledTimes(2); + }); + + it('should handle tool execution errors gracefully', async () => { + const mockTool = { + name: 'failing_tool', + description: 'A tool that fails', + schema: z.object({}), + invoke: jest.fn().mockRejectedValue(new Error('Tool execution failed')), + }; + + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llama3.2:latest'; + case 'messages.values': + return [{ role: 'user', content: 'Use the failing tool' }]; + case 'simplify': + return true; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]); + // @ts-expect-error: Mocking a tool, we do not implement the full interface + getConnectedToolsMock.mockResolvedValue([mockTool]); + + apiRequestMock.mockResolvedValueOnce({ + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + function: { + name: 'failing_tool', + arguments: {}, + }, + }, + ], + }, + done: true, + } as OllamaChatResponse); + + apiRequestMock.mockResolvedValueOnce({ + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { role: 'assistant', content: 'I encountered an error with the tool.' }, + done: true, + } as OllamaChatResponse); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { content: 'I encountered an error with the tool.' }, + pairedItem: { item: 0 }, + }, + ]); + + const secondCallBody = apiRequestMock.mock.calls[1][2]?.body as any; + const toolMessage = secondCallBody.messages.find((msg: OllamaMessage) => msg.role === 'tool'); + expect(toolMessage.content).toBe('Error executing tool: Tool execution failed'); + }); + + it('should process stop sequences correctly', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llama3.2:latest'; + case 'messages.values': + return [{ role: 'user', content: 'Generate text' }]; + case 'simplify': + return true; + case 'options': + return { + stop: '###,END,STOP', + }; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + getConnectedToolsMock.mockResolvedValue([]); + apiRequestMock.mockResolvedValue({ + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { role: 'assistant', content: 'Generated text' }, + done: true, + } as OllamaChatResponse); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', { + body: { + model: 'llama3.2:latest', + messages: [{ role: 'user', content: 'Generate text' }], + stream: false, + tools: [], + options: { + stop: ['###', 'END', 'STOP'], + }, + }, + }); + }); + + it('should handle various model-specific options', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llama3.2:latest'; + case 'messages.values': + return [{ role: 'user', content: 'Test with options' }]; + case 'simplify': + return true; + case 'options': + return { + temperature: 0.5, + top_p: 0.8, + top_k: 30, + num_predict: 512, + frequency_penalty: 0.1, + presence_penalty: 0.2, + repeat_penalty: 1.2, + num_ctx: 2048, + repeat_last_n: 32, + min_p: 0.1, + seed: 123, + low_vram: true, + main_gpu: 1, + num_batch: 256, + num_gpu: 2, + num_thread: 8, + penalize_newline: false, + use_mlock: true, + use_mmap: false, + vocab_only: false, + keep_alive: '10m', + format: 'json', + }; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + getConnectedToolsMock.mockResolvedValue([]); + apiRequestMock.mockResolvedValue({ + model: 'llama3.2:latest', + created_at: '2023-10-01T10:00:00Z', + message: { role: 'assistant', content: '{"response": "test"}' }, + done: true, + } as OllamaChatResponse); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', { + body: { + model: 'llama3.2:latest', + messages: [{ role: 'user', content: 'Test with options' }], + stream: false, + tools: [], + options: { + temperature: 0.5, + top_p: 0.8, + top_k: 30, + num_predict: 512, + frequency_penalty: 0.1, + presence_penalty: 0.2, + repeat_penalty: 1.2, + num_ctx: 2048, + repeat_last_n: 32, + min_p: 0.1, + seed: 123, + low_vram: true, + main_gpu: 1, + num_batch: 256, + num_gpu: 2, + num_thread: 8, + penalize_newline: false, + use_mlock: true, + use_mmap: false, + vocab_only: false, + keep_alive: '10m', + format: 'json', + }, + }, + }); + }); + }); + + describe('Image -> Analyze', () => { + it('should analyze image from binary data', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llava:latest'; + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'data'; + case 'text': + return "What's in this image?"; + case 'simplify': + return true; + case 'options': + return { + temperature: 0.3, + num_predict: 512, + }; + default: + return undefined; + } + }); + + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue( + Buffer.from('test image data'), + ); + apiRequestMock.mockResolvedValue({ + model: 'llava:latest', + created_at: '2023-10-01T10:00:00Z', + message: { + role: 'assistant', + content: 'This image shows a beautiful mountain landscape with snow-capped peaks.', + }, + done: true, + } as OllamaChatResponse); + + const result = await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: 'This image shows a beautiful mountain landscape with snow-capped peaks.', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', { + body: { + model: 'llava:latest', + messages: [ + { + role: 'user', + content: "What's in this image?", + images: ['dGVzdCBpbWFnZSBkYXRh'], // base64 encoded 'test image data' + }, + ], + stream: false, + options: { + temperature: 0.3, + num_predict: 512, + }, + }, + }); + }); + + it('should analyze image from URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llava:latest'; + case 'inputType': + return 'url'; + case 'imageUrls': + return 'https://example.com/test-image.jpg'; + case 'text': + return 'Describe this image'; + case 'simplify': + return true; + case 'options': + return {}; + default: + return undefined; + } + }); + + executeFunctionsMock.helpers.httpRequest.mockResolvedValue( + Buffer.from('downloaded image data'), + ); + apiRequestMock.mockResolvedValue({ + model: 'llava:latest', + created_at: '2023-10-01T10:00:00Z', + message: { + role: 'assistant', + content: 'This image contains a sunset over the ocean.', + }, + done: true, + } as OllamaChatResponse); + + const result = await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { content: 'This image contains a sunset over the ocean.' }, + pairedItem: { item: 0 }, + }, + ]); + expect(executeFunctionsMock.helpers.httpRequest).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://example.com/test-image.jpg', + encoding: 'arraybuffer', + }); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', { + body: { + model: 'llava:latest', + messages: [ + { + role: 'user', + content: 'Describe this image', + images: ['ZG93bmxvYWRlZCBpbWFnZSBkYXRh'], // base64 encoded 'downloaded image data' + }, + ], + stream: false, + options: {}, + }, + }); + }); + + it('should handle multiple images from URLs', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llava:latest'; + case 'inputType': + return 'url'; + case 'imageUrls': + return 'https://example.com/image1.jpg, https://example.com/image2.png'; + case 'text': + return 'Compare these images'; + case 'simplify': + return true; + case 'options': + return {}; + default: + return undefined; + } + }); + + executeFunctionsMock.helpers.httpRequest.mockResolvedValueOnce( + Buffer.from('first image data'), + ); + executeFunctionsMock.helpers.httpRequest.mockResolvedValueOnce( + Buffer.from('second image data'), + ); + apiRequestMock.mockResolvedValue({ + model: 'llava:latest', + created_at: '2023-10-01T10:00:00Z', + message: { + role: 'assistant', + content: 'Both images show different landscapes.', + }, + done: true, + } as OllamaChatResponse); + + const result = await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { content: 'Both images show different landscapes.' }, + pairedItem: { item: 0 }, + }, + ]); + expect(executeFunctionsMock.helpers.httpRequest).toHaveBeenCalledTimes(2); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', { + body: { + model: 'llava:latest', + messages: [ + { + role: 'user', + content: 'Compare these images', + images: [ + 'Zmlyc3QgaW1hZ2UgZGF0YQ==', // base64 encoded 'first image data' + 'c2Vjb25kIGltYWdlIGRhdGE=', // base64 encoded 'second image data' + ], + }, + ], + stream: false, + options: {}, + }, + }); + }); + + it('should handle multiple binary images', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llava:latest'; + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'image1,image2'; + case 'text': + return 'Analyze these images'; + case 'simplify': + return false; + case 'options': + return {}; + default: + return undefined; + } + }); + + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValueOnce( + Buffer.from('first binary image'), + ); + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValueOnce( + Buffer.from('second binary image'), + ); + const mockResponse = { + model: 'llava:latest', + created_at: '2023-10-01T10:00:00Z', + message: { + role: 'assistant', + content: 'Analysis complete for both images.', + }, + done: true, + eval_count: 25, + eval_duration: 3000000, + } as OllamaChatResponse; + apiRequestMock.mockResolvedValue(mockResponse); + + const result = await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: mockResponse, + pairedItem: { item: 0 }, + }, + ]); + expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenCalledTimes(2); + expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenNthCalledWith( + 1, + 0, + 'image1', + ); + expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenNthCalledWith( + 2, + 0, + 'image2', + ); + }); + + it('should process stop sequences for image analysis', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'llava:latest'; + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'data'; + case 'text': + return 'Describe briefly'; + case 'simplify': + return true; + case 'options': + return { + stop: 'END,DONE', + temperature: 0.1, + }; + default: + return undefined; + } + }); + + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(Buffer.from('test image')); + apiRequestMock.mockResolvedValue({ + model: 'llava:latest', + created_at: '2023-10-01T10:00:00Z', + message: { + role: 'assistant', + content: 'A simple image.', + }, + done: true, + } as OllamaChatResponse); + + await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', { + body: { + model: 'llava:latest', + messages: [ + { + role: 'user', + content: 'Describe briefly', + images: ['dGVzdCBpbWFnZQ=='], // base64 encoded 'test image' + }, + ], + stream: false, + options: { + stop: ['END', 'DONE'], + temperature: 0.1, + }, + }, + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/Ollama.node.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/Ollama.node.ts new file mode 100644 index 00000000000..65160874cd5 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/Ollama.node.ts @@ -0,0 +1,17 @@ +import type { IExecuteFunctions, INodeType } from 'n8n-workflow'; + +import { router } from './actions/router'; +import { versionDescription } from './actions/versionDescription'; +import { listSearch } from './methods'; + +export class Ollama implements INodeType { + description = versionDescription; + + methods = { + listSearch, + }; + + async execute(this: IExecuteFunctions) { + return await router.call(this); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/descriptions.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/descriptions.ts new file mode 100644 index 00000000000..7a5fe949d5b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/descriptions.ts @@ -0,0 +1,26 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const modelRLC: INodeProperties = { + displayName: 'Model', + name: 'modelId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'modelSearch', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. llava, llama3.2-vision', + }, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/image/analyze.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/image/analyze.operation.ts new file mode 100644 index 00000000000..7cf3fdf850c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/image/analyze.operation.ts @@ -0,0 +1,456 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import type { OllamaChatResponse, OllamaMessage } from '../../helpers'; +import { apiRequest } from '../../transport'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC, + { + displayName: 'Text Input', + name: 'text', + type: 'string', + placeholder: "e.g. What's in this image?", + default: "What's in this image?", + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Input Type', + name: 'inputType', + type: 'options', + default: 'binary', + options: [ + { + name: 'Binary File(s)', + value: 'binary', + }, + { + name: 'Image URL(s)', + value: 'url', + }, + ], + }, + { + displayName: 'Input Data Field Name(s)', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be processed', + description: + 'Name of the binary field(s) which contains the image(s), separate multiple field names with commas', + displayOptions: { + show: { + inputType: ['binary'], + }, + }, + }, + { + displayName: 'URL(s)', + name: 'imageUrls', + type: 'string', + placeholder: 'e.g. https://example.com/image.png', + description: 'URL(s) of the image(s) to analyze, multiple URLs can be added separated by comma', + default: '', + displayOptions: { + show: { + inputType: ['url'], + }, + }, + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to simplify the response or not', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'System Message', + name: 'system', + type: 'string', + default: '', + placeholder: 'e.g. You are a helpful assistant.', + description: 'System message to set the context for the conversation', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Temperature', + name: 'temperature', + type: 'number', + default: 0.8, + typeOptions: { + minValue: 0, + maxValue: 2, + numberPrecision: 2, + }, + description: 'Controls randomness in responses. Lower values make output more focused.', + }, + { + displayName: 'Output Randomness (Top P)', + name: 'top_p', + default: 0.7, + description: 'The maximum cumulative probability of tokens to consider when sampling', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 1, + numberPrecision: 1, + }, + }, + { + displayName: 'Top K', + name: 'top_k', + type: 'number', + default: 40, + typeOptions: { + minValue: 1, + }, + description: 'Controls diversity by limiting the number of top tokens to consider', + }, + { + displayName: 'Max Tokens', + name: 'num_predict', + type: 'number', + default: 1024, + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + description: 'Maximum number of tokens to generate in the completion', + }, + { + displayName: 'Frequency Penalty', + name: 'frequency_penalty', + type: 'number', + default: 0.0, + typeOptions: { + minValue: 0, + numberPrecision: 2, + }, + description: + 'Adjusts the penalty for tokens that have already appeared in the generated text. Higher values discourage repetition.', + }, + { + displayName: 'Presence Penalty', + name: 'presence_penalty', + type: 'number', + default: 0.0, + typeOptions: { + numberPrecision: 2, + }, + description: + 'Adjusts the penalty for tokens based on their presence in the generated text so far. Positive values penalize tokens that have already appeared, encouraging diversity.', + }, + { + displayName: 'Repetition Penalty', + name: 'repeat_penalty', + type: 'number', + default: 1.1, + typeOptions: { + minValue: 0, + numberPrecision: 2, + }, + description: + 'Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient.', + }, + { + displayName: 'Context Length', + name: 'num_ctx', + type: 'number', + default: 4096, + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + description: 'Sets the size of the context window used to generate the next token', + }, + { + displayName: 'Repeat Last N', + name: 'repeat_last_n', + type: 'number', + default: 64, + typeOptions: { + minValue: -1, + numberPrecision: 0, + }, + description: + 'Sets how far back for the model to look back to prevent repetition. (0 = disabled, -1 = num_ctx).', + }, + { + displayName: 'Min P', + name: 'min_p', + type: 'number', + default: 0.0, + typeOptions: { + minValue: 0, + maxValue: 1, + numberPrecision: 3, + }, + description: + 'Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token.', + }, + { + displayName: 'Seed', + name: 'seed', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + numberPrecision: 0, + }, + description: + 'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.', + }, + { + displayName: 'Stop Sequences', + name: 'stop', + type: 'string', + default: '', + description: + 'Sets the stop sequences to use. When this pattern is encountered the LLM will stop generating text and return. Separate multiple patterns with commas', + }, + { + displayName: 'Keep Alive', + name: 'keep_alive', + type: 'string', + default: '5m', + description: + 'Specifies the duration to keep the loaded model in memory after use. Format: 1h30m (1 hour 30 minutes).', + }, + { + displayName: 'Low VRAM Mode', + name: 'low_vram', + type: 'boolean', + default: false, + description: + 'Whether to activate low VRAM mode, which reduces memory usage at the cost of slower generation speed. Useful for GPUs with limited memory.', + }, + { + displayName: 'Main GPU ID', + name: 'main_gpu', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + numberPrecision: 0, + }, + description: + 'Specifies the ID of the GPU to use for the main computation. Only change this if you have multiple GPUs.', + }, + { + displayName: 'Context Batch Size', + name: 'num_batch', + type: 'number', + default: 512, + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + description: + 'Sets the batch size for prompt processing. Larger batch sizes may improve generation speed but increase memory usage.', + }, + { + displayName: 'Number of GPUs', + name: 'num_gpu', + type: 'number', + default: -1, + typeOptions: { + minValue: -1, + numberPrecision: 0, + }, + description: + 'Specifies the number of GPUs to use for parallel processing. Set to -1 for auto-detection.', + }, + { + displayName: 'Number of CPU Threads', + name: 'num_thread', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + numberPrecision: 0, + }, + description: + 'Specifies the number of CPU threads to use for processing. Set to 0 for auto-detection.', + }, + { + displayName: 'Penalize Newlines', + name: 'penalize_newline', + type: 'boolean', + default: true, + description: + 'Whether the model will be less likely to generate newline characters, encouraging longer continuous sequences of text', + }, + { + displayName: 'Use Memory Locking', + name: 'use_mlock', + type: 'boolean', + default: false, + description: + 'Whether to lock the model in memory to prevent swapping. This can improve performance but requires sufficient available memory.', + }, + { + displayName: 'Use Memory Mapping', + name: 'use_mmap', + type: 'boolean', + default: true, + description: + 'Whether to use memory mapping for loading the model. This can reduce memory usage but may impact performance.', + }, + { + displayName: 'Load Vocabulary Only', + name: 'vocab_only', + type: 'boolean', + default: false, + description: + 'Whether to only load the model vocabulary without the weights. Useful for quickly testing tokenization.', + }, + { + displayName: 'Output Format', + name: 'format', + type: 'options', + options: [ + { name: 'Default', value: '' }, + { name: 'JSON', value: 'json' }, + ], + default: '', + description: 'Specifies the format of the API response', + }, + ], + }, +]; + +interface MessageOptions { + system?: string; + temperature?: number; + top_p?: number; + top_k?: number; + num_predict?: number; + frequency_penalty?: number; + presence_penalty?: number; + repeat_penalty?: number; + num_ctx?: number; + repeat_last_n?: number; + min_p?: number; + seed?: number; + stop?: string | string[]; + low_vram?: boolean; + main_gpu?: number; + num_batch?: number; + num_gpu?: number; + num_thread?: number; + penalize_newline?: boolean; + use_mlock?: boolean; + use_mmap?: boolean; + vocab_only?: boolean; + format?: string; + keep_alive?: string; +} + +const displayOptions = { + show: { + operation: ['analyze'], + resource: ['image'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string; + const inputType = this.getNodeParameter('inputType', i, 'binary') as string; + const text = this.getNodeParameter('text', i, '') as string; + const simplify = this.getNodeParameter('simplify', i, true) as boolean; + const options = this.getNodeParameter('options', i, {}) as MessageOptions; + + let images: string[]; + + if (inputType === 'url') { + const urls = this.getNodeParameter('imageUrls', i, '') as string; + const urlList = urls + .split(',') + .map((url) => url.trim()) + .filter((url) => url); + + // For URL inputs, we need to download and convert to base64 + const imagePromises = urlList.map(async (url) => { + const response = (await this.helpers.httpRequest({ + method: 'GET', + url, + encoding: 'arraybuffer', + })) as Buffer; + return response.toString('base64'); + }); + + images = await Promise.all(imagePromises); + } else { + const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data'); + const propertyNames = binaryPropertyNames + .split(',') + .map((name: string) => name.trim()) + .filter((name: string) => name); + + const imagePromises = propertyNames.map(async (binaryPropertyName: string) => { + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + return buffer.toString('base64'); + }); + + images = await Promise.all(imagePromises); + } + + const messages: OllamaMessage[] = [ + { + role: 'user', + content: text, + images, + }, + ]; + + const processedOptions = { ...options }; + if (processedOptions.stop && typeof processedOptions.stop === 'string') { + processedOptions.stop = processedOptions.stop + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); + } + + const body = { + model, + messages, + stream: false, + options: processedOptions, + }; + + const response: OllamaChatResponse = await apiRequest.call(this, 'POST', '/api/chat', { + body, + }); + + if (simplify) { + return [ + { + json: { content: response.message.content }, + pairedItem: { item: i }, + }, + ]; + } + + return [ + { + json: { ...response }, + pairedItem: { item: i }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/image/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/image/index.ts new file mode 100644 index 00000000000..313ee44db42 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/image/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as analyze from './analyze.operation'; + +export { analyze }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Analyze Image', + value: 'analyze', + action: 'Analyze image', + description: 'Take in images and answer questions about them', + }, + ], + default: 'analyze', + displayOptions: { + show: { + resource: ['image'], + }, + }, + }, + ...analyze.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/node.type.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/node.type.ts new file mode 100644 index 00000000000..45ee4672957 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/node.type.ts @@ -0,0 +1,8 @@ +import type { AllEntities } from 'n8n-workflow'; + +type NodeMap = { + text: 'message'; + image: 'analyze'; +}; + +export type OllamaType = AllEntities; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/router.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/router.test.ts new file mode 100644 index 00000000000..93edf1c3268 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/router.test.ts @@ -0,0 +1,226 @@ +import { mockDeep } from 'jest-mock-extended'; +import { NodeOperationError, type IExecuteFunctions, type INode } from 'n8n-workflow'; + +import * as image from './image'; +import * as text from './text'; +import { router } from './router'; + +jest.mock('./image'); +jest.mock('./text'); + +describe('Ollama Router', () => { + const executeFunctionsMock = mockDeep(); + const mockImageExecute = jest.fn(); + const mockTextExecute = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + (image as any).analyze = { execute: mockImageExecute }; + (text as any).message = { execute: mockTextExecute }; + + executeFunctionsMock.getInputData.mockReturnValue([ + { json: { input: 'test1' } }, + { json: { input: 'test2' } }, + ]); + }); + + describe('router', () => { + it('should route to text.message operation', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'text'; + if (parameter === 'operation') return 'message'; + return undefined; + }); + + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'response1' }, pairedItem: { item: 0 } }, + ]); + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'response2' }, pairedItem: { item: 1 } }, + ]); + + const result = await router.call(executeFunctionsMock); + + expect(result).toEqual([ + [ + { json: { result: 'response1' }, pairedItem: { item: 0 } }, + { json: { result: 'response2' }, pairedItem: { item: 1 } }, + ], + ]); + expect(mockTextExecute).toHaveBeenCalledTimes(2); + expect(mockTextExecute).toHaveBeenNthCalledWith(1, 0); + expect(mockTextExecute).toHaveBeenNthCalledWith(2, 1); + }); + + it('should route to image.analyze operation', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'image'; + if (parameter === 'operation') return 'analyze'; + return undefined; + }); + + mockImageExecute.mockResolvedValueOnce([ + { json: { result: 'image analysis 1' }, pairedItem: { item: 0 } }, + ]); + mockImageExecute.mockResolvedValueOnce([ + { json: { result: 'image analysis 2' }, pairedItem: { item: 1 } }, + ]); + + const result = await router.call(executeFunctionsMock); + + expect(result).toEqual([ + [ + { json: { result: 'image analysis 1' }, pairedItem: { item: 0 } }, + { json: { result: 'image analysis 2' }, pairedItem: { item: 1 } }, + ], + ]); + expect(mockImageExecute).toHaveBeenCalledTimes(2); + expect(mockImageExecute).toHaveBeenNthCalledWith(1, 0); + expect(mockImageExecute).toHaveBeenNthCalledWith(2, 1); + }); + + it('should throw error for unsupported resource', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'unsupported'; + if (parameter === 'operation') return 'test'; + return undefined; + }); + + const mockNode = { name: 'Ollama', type: 'n8n-nodes-langchain.ollama' } as INode; + executeFunctionsMock.getNode.mockReturnValue(mockNode); + + await expect(router.call(executeFunctionsMock)).rejects.toThrow(NodeOperationError); + await expect(router.call(executeFunctionsMock)).rejects.toThrow( + 'The resource "unsupported" is not supported!', + ); + }); + + it('should handle execution errors with continueOnFail enabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'text'; + if (parameter === 'operation') return 'message'; + return undefined; + }); + + executeFunctionsMock.continueOnFail.mockReturnValue(true); + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'success' }, pairedItem: { item: 0 } }, + ]); + mockTextExecute.mockRejectedValueOnce(new Error('API Error')); + + const result = await router.call(executeFunctionsMock); + + expect(result).toEqual([ + [ + { json: { result: 'success' }, pairedItem: { item: 0 } }, + { json: { error: 'API Error' }, pairedItem: { item: 1 } }, + ], + ]); + expect(mockTextExecute).toHaveBeenCalledTimes(2); + }); + + it('should throw NodeOperationError when continueOnFail is disabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'text'; + if (parameter === 'operation') return 'message'; + return undefined; + }); + + executeFunctionsMock.continueOnFail.mockReturnValue(false); + const mockNode = { name: 'Ollama', type: 'n8n-nodes-langchain.ollama' } as INode; + executeFunctionsMock.getNode.mockReturnValue(mockNode); + + const originalError = new Error('API Connection Failed'); + mockTextExecute.mockRejectedValueOnce(originalError); + + await expect(router.call(executeFunctionsMock)).rejects.toThrow(NodeOperationError); + }); + + it('should process multiple items and accumulate results', async () => { + executeFunctionsMock.getInputData.mockReturnValue([ + { json: { input: 'test1' } }, + { json: { input: 'test2' } }, + { json: { input: 'test3' } }, + ]); + + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'text'; + if (parameter === 'operation') return 'message'; + return undefined; + }); + + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'response1' }, pairedItem: { item: 0 } }, + ]); + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'response2a' }, pairedItem: { item: 1 } }, + { json: { result: 'response2b' }, pairedItem: { item: 1 } }, + ]); + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'response3' }, pairedItem: { item: 2 } }, + ]); + + const result = await router.call(executeFunctionsMock); + + expect(result).toEqual([ + [ + { json: { result: 'response1' }, pairedItem: { item: 0 } }, + { json: { result: 'response2a' }, pairedItem: { item: 1 } }, + { json: { result: 'response2b' }, pairedItem: { item: 1 } }, + { json: { result: 'response3' }, pairedItem: { item: 2 } }, + ], + ]); + expect(mockTextExecute).toHaveBeenCalledTimes(3); + expect(mockTextExecute).toHaveBeenNthCalledWith(1, 0); + expect(mockTextExecute).toHaveBeenNthCalledWith(2, 1); + expect(mockTextExecute).toHaveBeenNthCalledWith(3, 2); + }); + + it('should handle empty input data', async () => { + executeFunctionsMock.getInputData.mockReturnValue([]); + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'text'; + if (parameter === 'operation') return 'message'; + return undefined; + }); + + const result = await router.call(executeFunctionsMock); + + expect(result).toEqual([[]]); + expect(mockTextExecute).not.toHaveBeenCalled(); + }); + + it('should handle mixed success and failure with continueOnFail', async () => { + executeFunctionsMock.getInputData.mockReturnValue([ + { json: { input: 'test1' } }, + { json: { input: 'test2' } }, + { json: { input: 'test3' } }, + ]); + + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + if (parameter === 'resource') return 'text'; + if (parameter === 'operation') return 'message'; + return undefined; + }); + + executeFunctionsMock.continueOnFail.mockReturnValue(true); + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'success1' }, pairedItem: { item: 0 } }, + ]); + mockTextExecute.mockRejectedValueOnce(new Error('Error in item 2')); + mockTextExecute.mockResolvedValueOnce([ + { json: { result: 'success3' }, pairedItem: { item: 2 } }, + ]); + + const result = await router.call(executeFunctionsMock); + + expect(result).toEqual([ + [ + { json: { result: 'success1' }, pairedItem: { item: 0 } }, + { json: { error: 'Error in item 2' }, pairedItem: { item: 1 } }, + { json: { result: 'success3' }, pairedItem: { item: 2 } }, + ], + ]); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/router.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/router.ts new file mode 100644 index 00000000000..0a67997c7ed --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/router.ts @@ -0,0 +1,49 @@ +import { NodeOperationError, type IExecuteFunctions, type INodeExecutionData } from 'n8n-workflow'; + +import * as image from './image'; +import type { OllamaType } from './node.type'; +import * as text from './text'; + +export async function router(this: IExecuteFunctions) { + const returnData: INodeExecutionData[] = []; + + const items = this.getInputData(); + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + const ollamaTypeData = { + resource, + operation, + } as OllamaType; + + let execute; + switch (ollamaTypeData.resource) { + case 'image': + execute = image[ollamaTypeData.operation].execute; + break; + case 'text': + execute = text[ollamaTypeData.operation].execute; + break; + default: + throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not supported!`); + } + + for (let i = 0; i < items.length; i++) { + try { + const responseData = await execute.call(this, i); + returnData.push.apply(returnData, responseData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { error: error.message }, pairedItem: { item: i } }); + continue; + } + + throw new NodeOperationError(this.getNode(), error, { + itemIndex: i, + description: error.description, + }); + } + } + + return [returnData]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/text/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/text/index.ts new file mode 100644 index 00000000000..f796b34fe38 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/text/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as message from './message.operation'; + +export { message }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Message a Model', + value: 'message', + action: 'Message a model', + description: 'Send a message to Ollama model', + }, + ], + default: 'message', + displayOptions: { + show: { + resource: ['text'], + }, + }, + }, + ...message.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/text/message.operation.ts new file mode 100644 index 00000000000..d8bdfd4ae8d --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/text/message.operation.ts @@ -0,0 +1,489 @@ +import type { Tool } from '@langchain/core/tools'; +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { getConnectedTools } from '@utils/helpers'; + +import type { OllamaChatResponse, OllamaMessage, OllamaTool } from '../../helpers'; +import { apiRequest } from '../../transport'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC, + { + displayName: 'Messages', + name: 'messages', + type: 'fixedCollection', + typeOptions: { + sortable: true, + multipleValues: true, + }, + placeholder: 'Add Message', + default: { values: [{ content: '', role: 'user' }] }, + options: [ + { + displayName: 'Values', + name: 'values', + values: [ + { + displayName: 'Content', + name: 'content', + type: 'string', + description: 'The content of the message to be sent', + default: '', + placeholder: 'e.g. Hello, how can you help me?', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Role', + name: 'role', + type: 'options', + description: 'The role of this message in the conversation', + options: [ + { + name: 'User', + value: 'user', + description: 'Message from the user', + }, + { + name: 'Assistant', + value: 'assistant', + description: 'Response from the assistant (for conversation history)', + }, + ], + default: 'user', + }, + ], + }, + ], + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to simplify the response or not', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'System Message', + name: 'system', + type: 'string', + default: '', + placeholder: 'e.g. You are a helpful assistant.', + description: 'System message to set the context for the conversation', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Temperature', + name: 'temperature', + type: 'number', + default: 0.8, + typeOptions: { + minValue: 0, + maxValue: 2, + numberPrecision: 2, + }, + description: 'Controls randomness in responses. Lower values make output more focused.', + }, + { + displayName: 'Output Randomness (Top P)', + name: 'top_p', + default: 0.7, + description: 'The maximum cumulative probability of tokens to consider when sampling', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 1, + numberPrecision: 1, + }, + }, + { + displayName: 'Top K', + name: 'top_k', + type: 'number', + default: 40, + typeOptions: { + minValue: 1, + }, + description: 'Controls diversity by limiting the number of top tokens to consider', + }, + { + displayName: 'Max Tokens', + name: 'num_predict', + type: 'number', + default: 1024, + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + description: 'Maximum number of tokens to generate in the completion', + }, + { + displayName: 'Frequency Penalty', + name: 'frequency_penalty', + type: 'number', + default: 0.0, + typeOptions: { + minValue: 0, + numberPrecision: 2, + }, + description: + 'Adjusts the penalty for tokens that have already appeared in the generated text. Higher values discourage repetition.', + }, + { + displayName: 'Presence Penalty', + name: 'presence_penalty', + type: 'number', + default: 0.0, + typeOptions: { + numberPrecision: 2, + }, + description: + 'Adjusts the penalty for tokens based on their presence in the generated text so far. Positive values penalize tokens that have already appeared, encouraging diversity.', + }, + { + displayName: 'Repetition Penalty', + name: 'repeat_penalty', + type: 'number', + default: 1.1, + typeOptions: { + minValue: 0, + numberPrecision: 2, + }, + description: + 'Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient.', + }, + { + displayName: 'Context Length', + name: 'num_ctx', + type: 'number', + default: 4096, + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + description: 'Sets the size of the context window used to generate the next token', + }, + { + displayName: 'Repeat Last N', + name: 'repeat_last_n', + type: 'number', + default: 64, + typeOptions: { + minValue: -1, + numberPrecision: 0, + }, + description: + 'Sets how far back for the model to look back to prevent repetition. (0 = disabled, -1 = num_ctx).', + }, + { + displayName: 'Min P', + name: 'min_p', + type: 'number', + default: 0.0, + typeOptions: { + minValue: 0, + maxValue: 1, + numberPrecision: 3, + }, + description: + 'Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token.', + }, + { + displayName: 'Seed', + name: 'seed', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + numberPrecision: 0, + }, + description: + 'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.', + }, + { + displayName: 'Stop Sequences', + name: 'stop', + type: 'string', + default: '', + description: + 'Sets the stop sequences to use. When this pattern is encountered the LLM will stop generating text and return. Separate multiple patterns with commas', + }, + { + displayName: 'Keep Alive', + name: 'keep_alive', + type: 'string', + default: '5m', + description: + 'Specifies the duration to keep the loaded model in memory after use. Format: 1h30m (1 hour 30 minutes).', + }, + { + displayName: 'Low VRAM Mode', + name: 'low_vram', + type: 'boolean', + default: false, + description: + 'Whether to activate low VRAM mode, which reduces memory usage at the cost of slower generation speed. Useful for GPUs with limited memory.', + }, + { + displayName: 'Main GPU ID', + name: 'main_gpu', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + numberPrecision: 0, + }, + description: + 'Specifies the ID of the GPU to use for the main computation. Only change this if you have multiple GPUs.', + }, + { + displayName: 'Context Batch Size', + name: 'num_batch', + type: 'number', + default: 512, + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + description: + 'Sets the batch size for prompt processing. Larger batch sizes may improve generation speed but increase memory usage.', + }, + { + displayName: 'Number of GPUs', + name: 'num_gpu', + type: 'number', + default: -1, + typeOptions: { + minValue: -1, + numberPrecision: 0, + }, + description: + 'Specifies the number of GPUs to use for parallel processing. Set to -1 for auto-detection.', + }, + { + displayName: 'Number of CPU Threads', + name: 'num_thread', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + numberPrecision: 0, + }, + description: + 'Specifies the number of CPU threads to use for processing. Set to 0 for auto-detection.', + }, + { + displayName: 'Penalize Newlines', + name: 'penalize_newline', + type: 'boolean', + default: true, + description: + 'Whether the model will be less likely to generate newline characters, encouraging longer continuous sequences of text', + }, + { + displayName: 'Use Memory Locking', + name: 'use_mlock', + type: 'boolean', + default: false, + description: + 'Whether to lock the model in memory to prevent swapping. This can improve performance but requires sufficient available memory.', + }, + { + displayName: 'Use Memory Mapping', + name: 'use_mmap', + type: 'boolean', + default: true, + description: + 'Whether to use memory mapping for loading the model. This can reduce memory usage but may impact performance.', + }, + { + displayName: 'Load Vocabulary Only', + name: 'vocab_only', + type: 'boolean', + default: false, + description: + 'Whether to only load the model vocabulary without the weights. Useful for quickly testing tokenization.', + }, + { + displayName: 'Output Format', + name: 'format', + type: 'options', + options: [ + { name: 'Default', value: '' }, + { name: 'JSON', value: 'json' }, + ], + default: '', + description: 'Specifies the format of the API response', + }, + ], + }, +]; + +interface MessageOptions { + system?: string; + temperature?: number; + top_p?: number; + top_k?: number; + num_predict?: number; + frequency_penalty?: number; + presence_penalty?: number; + repeat_penalty?: number; + num_ctx?: number; + repeat_last_n?: number; + min_p?: number; + seed?: number; + stop?: string | string[]; + low_vram?: boolean; + main_gpu?: number; + num_batch?: number; + num_gpu?: number; + num_thread?: number; + penalize_newline?: boolean; + use_mlock?: boolean; + use_mmap?: boolean; + vocab_only?: boolean; + format?: string; + keep_alive?: string; +} + +const displayOptions = { + show: { + operation: ['message'], + resource: ['text'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string; + const messages = this.getNodeParameter('messages.values', i, []) as OllamaMessage[]; + const simplify = this.getNodeParameter('simplify', i, true) as boolean; + const options = this.getNodeParameter('options', i, {}) as MessageOptions; + const { tools, connectedTools } = await getTools.call(this); + + if (options.system) { + messages.unshift({ + role: 'system', + content: options.system, + }); + } + + delete options.system; + + const processedOptions = { ...options }; + if (processedOptions.stop && typeof processedOptions.stop === 'string') { + processedOptions.stop = processedOptions.stop + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); + } + + const body = { + model, + messages, + stream: false, + tools, + options: processedOptions, + }; + + let response: OllamaChatResponse = await apiRequest.call(this, 'POST', '/api/chat', { + body, + }); + + if (tools.length > 0 && response.message.tool_calls && response.message.tool_calls.length > 0) { + const toolCalls = response.message.tool_calls; + + messages.push(response.message); + + for (const toolCall of toolCalls) { + let toolResponse = ''; + let toolFound = false; + + for (const tool of connectedTools) { + if (tool.name === toolCall.function.name) { + toolFound = true; + try { + const result: unknown = await tool.invoke(toolCall.function.arguments); + toolResponse = + typeof result === 'object' && result !== null + ? JSON.stringify(result) + : String(result); + } catch (error) { + toolResponse = `Error executing tool: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + break; + } + } + + // Add tool response even if tool wasn't found to prevent silent failure + if (!toolFound) { + toolResponse = `Error: Tool '${toolCall.function.name}' not found`; + } + + messages.push({ + role: 'tool', + content: toolResponse, + tool_name: toolCall.function.name, + }); + } + + const updatedBody = { + ...body, + messages, + }; + + response = await apiRequest.call(this, 'POST', '/api/chat', { + body: updatedBody, + }); + } + + if (simplify) { + return [ + { + json: { content: response.message.content }, + pairedItem: { item: i }, + }, + ]; + } + + return [ + { + json: { ...response }, + pairedItem: { item: i }, + }, + ]; +} + +async function getTools(this: IExecuteFunctions) { + let connectedTools: Tool[] = []; + const nodeInputs = this.getNodeInputs(); + + if (nodeInputs.some((input) => input.type === 'ai_tool')) { + connectedTools = await getConnectedTools(this, true); + } + + const tools: OllamaTool[] = connectedTools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: zodToJsonSchema(tool.schema), + }, + })); + + return { tools, connectedTools }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/versionDescription.ts new file mode 100644 index 00000000000..19ef0ddf2a2 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/actions/versionDescription.ts @@ -0,0 +1,72 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow'; + +import * as image from './image'; +import * as text from './text'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Ollama', + name: 'ollama', + icon: 'file:ollama.svg', + group: ['transform'], + version: 1, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', + description: 'Interact with Ollama AI models', + defaults: { + name: 'Ollama', + }, + usableAsTool: true, + codex: { + alias: ['LangChain', 'image', 'vision', 'AI', 'local'], + categories: ['AI'], + subcategories: { + AI: ['Agents', 'Miscellaneous', 'Root Nodes'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.ollama/', + }, + ], + }, + }, + inputs: `={{ + (() => { + const resource = $parameter.resource; + const operation = $parameter.operation; + if (resource === 'text' && operation === 'message') { + return [{ type: 'main' }, { type: 'ai_tool', displayName: 'Tools' }]; + } + + return ['main']; + })() + }}`, + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'ollamaApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Image', + value: 'image', + }, + { + name: 'Text', + value: 'text', + }, + ], + default: 'text', + }, + ...image.description, + ...text.description, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/helpers/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/helpers/index.ts new file mode 100644 index 00000000000..56e2ab61fd0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/helpers/index.ts @@ -0,0 +1 @@ +export type * from './interfaces'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/helpers/interfaces.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/helpers/interfaces.ts new file mode 100644 index 00000000000..0aa2cebaca0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/helpers/interfaces.ts @@ -0,0 +1,55 @@ +export interface OllamaMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + images?: string[]; + tool_calls?: ToolCall[]; + tool_name?: string; +} + +export interface ToolCall { + function: { + name: string; + arguments: Record; + }; +} + +export interface OllamaTool { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +export interface OllamaChatResponse { + model: string; + created_at: string; + message: OllamaMessage; + done: boolean; + done_reason?: string; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; + prompt_eval_duration?: number; + eval_count?: number; + eval_duration?: number; +} + +export interface OllamaModel { + name: string; + modified_at: string; + size: number; + digest: string; + details: { + format: string; + family: string; + families: string[] | null; + parameter_size: string; + quantization_level: string; + }; +} + +export interface OllamaTagsResponse { + models: OllamaModel[]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/index.ts new file mode 100644 index 00000000000..c7fb720e474 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/listSearch.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/listSearch.test.ts new file mode 100644 index 00000000000..4faecff8b53 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/listSearch.test.ts @@ -0,0 +1,189 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import * as transport from '../transport'; +import { modelSearch } from './listSearch'; + +describe('Ollama List Search Methods', () => { + const loadOptionsFunctionsMock = mockDeep(); + const apiRequestMock = jest.spyOn(transport, 'apiRequest'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('modelSearch', () => { + it('should return all models when no filter is provided', async () => { + apiRequestMock.mockResolvedValue({ + models: [ + { name: 'llama3.2:latest' }, + { name: 'llama3.2:3b' }, + { name: 'codellama:latest' }, + { name: 'mistral:7b' }, + { name: 'phi3:latest' }, + ], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock); + + expect(result).toEqual({ + results: [ + { name: 'llama3.2:latest', value: 'llama3.2:latest' }, + { name: 'llama3.2:3b', value: 'llama3.2:3b' }, + { name: 'codellama:latest', value: 'codellama:latest' }, + { name: 'mistral:7b', value: 'mistral:7b' }, + { name: 'phi3:latest', value: 'phi3:latest' }, + ], + }); + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/api/tags'); + }); + + it('should filter models by name (case insensitive)', async () => { + apiRequestMock.mockResolvedValue({ + models: [ + { name: 'llama3.2:latest' }, + { name: 'llama3.2:3b' }, + { name: 'codellama:latest' }, + { name: 'mistral:7b' }, + { name: 'phi3:latest' }, + ], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, 'llama'); + + expect(result).toEqual({ + results: [ + { name: 'llama3.2:latest', value: 'llama3.2:latest' }, + { name: 'llama3.2:3b', value: 'llama3.2:3b' }, + { name: 'codellama:latest', value: 'codellama:latest' }, + ], + }); + }); + + it('should handle case insensitive filtering', async () => { + apiRequestMock.mockResolvedValue({ + models: [{ name: 'Llama3.2:latest' }, { name: 'CODELLAMA:latest' }, { name: 'mistral:7b' }], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, 'LLAMA'); + + expect(result).toEqual({ + results: [ + { name: 'Llama3.2:latest', value: 'Llama3.2:latest' }, + { name: 'CODELLAMA:latest', value: 'CODELLAMA:latest' }, + ], + }); + }); + + it('should return empty results when filter matches no models', async () => { + apiRequestMock.mockResolvedValue({ + models: [{ name: 'llama3.2:latest' }, { name: 'mistral:7b' }], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, 'gpt'); + + expect(result).toEqual({ + results: [], + }); + }); + + it('should handle empty model list', async () => { + apiRequestMock.mockResolvedValue({ + models: [], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock); + + expect(result).toEqual({ + results: [], + }); + }); + + it('should handle partial string matching', async () => { + apiRequestMock.mockResolvedValue({ + models: [ + { name: 'llama3.2:3b-instruct' }, + { name: 'llama3.2:7b' }, + { name: 'mistral:3b-instruct' }, + { name: 'phi3:3b' }, + ], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, '3b'); + + expect(result).toEqual({ + results: [ + { name: 'llama3.2:3b-instruct', value: 'llama3.2:3b-instruct' }, + { name: 'mistral:3b-instruct', value: 'mistral:3b-instruct' }, + { name: 'phi3:3b', value: 'phi3:3b' }, + ], + }); + }); + + it('should filter by tag', async () => { + apiRequestMock.mockResolvedValue({ + models: [ + { name: 'llama3.2:latest' }, + { name: 'llama3.2:3b' }, + { name: 'mistral:latest' }, + { name: 'codellama:7b' }, + ], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, 'latest'); + + expect(result).toEqual({ + results: [ + { name: 'llama3.2:latest', value: 'llama3.2:latest' }, + { name: 'mistral:latest', value: 'mistral:latest' }, + ], + }); + }); + + it('should handle special characters in filter', async () => { + apiRequestMock.mockResolvedValue({ + models: [ + { name: 'llama3.2:latest' }, + { name: 'model-with-dash:1.0' }, + { name: 'model_with_underscore:2.0' }, + ], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, 'with-dash'); + + expect(result).toEqual({ + results: [{ name: 'model-with-dash:1.0', value: 'model-with-dash:1.0' }], + }); + }); + + it('should handle undefined filter as no filter', async () => { + apiRequestMock.mockResolvedValue({ + models: [{ name: 'llama3.2:latest' }, { name: 'mistral:7b' }], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, undefined); + + expect(result).toEqual({ + results: [ + { name: 'llama3.2:latest', value: 'llama3.2:latest' }, + { name: 'mistral:7b', value: 'mistral:7b' }, + ], + }); + }); + + it('should handle empty string filter as no filter', async () => { + apiRequestMock.mockResolvedValue({ + models: [{ name: 'llama3.2:latest' }, { name: 'mistral:7b' }], + }); + + const result = await modelSearch.call(loadOptionsFunctionsMock, ''); + + expect(result).toEqual({ + results: [ + { name: 'llama3.2:latest', value: 'llama3.2:latest' }, + { name: 'mistral:7b', value: 'mistral:7b' }, + ], + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/listSearch.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/listSearch.ts new file mode 100644 index 00000000000..37013e176d6 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/methods/listSearch.ts @@ -0,0 +1,21 @@ +import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; + +import type { OllamaTagsResponse } from '../helpers/interfaces'; +import { apiRequest } from '../transport'; + +export async function modelSearch( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const response: OllamaTagsResponse = await apiRequest.call(this, 'GET', '/api/tags'); + + let models = response.models; + + if (filter) { + models = models.filter((model) => model.name.toLowerCase().includes(filter.toLowerCase())); + } + + return { + results: models.map((model) => ({ name: model.name, value: model.name })), + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/ollama.svg b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/ollama.svg new file mode 100644 index 00000000000..ced4564f6c5 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/ollama.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/transport/index.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/transport/index.test.ts new file mode 100644 index 00000000000..04278bc8e90 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/transport/index.test.ts @@ -0,0 +1,243 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow'; + +import { apiRequest } from './index'; + +describe('Ollama Transport', () => { + const executeFunctionsMock = mockDeep(); + const loadOptionsFunctionsMock = mockDeep(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('apiRequest', () => { + it('should make API request with basic auth', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + apiKey: 'test-api-key', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({ + model: 'test-model', + response: 'test response', + }); + + const result = await apiRequest.call(executeFunctionsMock, 'POST', '/api/chat', { + body: { model: 'test-model', messages: [] }, + }); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-api-key', + }, + method: 'POST', + body: { model: 'test-model', messages: [] }, + qs: undefined, + url: 'http://localhost:11434/api/chat', + json: true, + }, + ); + expect(result).toEqual({ model: 'test-model', response: 'test response' }); + }); + + it('should make API request without auth when no API key provided', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({ + model: 'test-model', + response: 'test response', + }); + + await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags'); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + body: undefined, + qs: undefined, + url: 'http://localhost:11434/api/tags', + json: true, + }, + ); + }); + + it('should handle query parameters', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue([]); + + await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags', { + qs: { limit: 10, offset: 0 }, + }); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + body: undefined, + qs: { limit: 10, offset: 0 }, + url: 'http://localhost:11434/api/tags', + json: true, + }, + ); + }); + + it('should handle custom headers', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + apiKey: 'test-key', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({}); + + await apiRequest.call(executeFunctionsMock, 'POST', '/api/generate', { + headers: { 'X-Custom-Header': 'custom-value' }, + body: { model: 'test', prompt: 'hello' }, + }); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-key', + 'X-Custom-Header': 'custom-value', + }, + method: 'POST', + body: { model: 'test', prompt: 'hello' }, + qs: undefined, + url: 'http://localhost:11434/api/generate', + json: true, + }, + ); + }); + + it('should handle additional options', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({}); + + await apiRequest.call(executeFunctionsMock, 'POST', '/api/chat', { + body: { model: 'test' }, + option: { timeout: 30000, encoding: 'utf8' }, + }); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: { model: 'test' }, + qs: undefined, + url: 'http://localhost:11434/api/chat', + json: true, + timeout: 30000, + encoding: 'utf8', + }, + ); + }); + + it('should work with ILoadOptionsFunctions', async () => { + loadOptionsFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + }); + loadOptionsFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({ + models: [{ name: 'llama3.2:latest' }], + }); + + const result = await apiRequest.call(loadOptionsFunctionsMock, 'GET', '/api/tags'); + + expect(loadOptionsFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + body: undefined, + qs: undefined, + url: 'http://localhost:11434/api/tags', + json: true, + }, + ); + expect(result).toEqual({ models: [{ name: 'llama3.2:latest' }] }); + }); + + it('should handle baseUrl with trailing slash', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434/', + apiKey: 'test-key', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({}); + + await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags'); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + expect.objectContaining({ + url: 'http://localhost:11434/api/tags', + }), + ); + }); + + it('should handle empty parameters object', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({}); + + await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags', {}); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + body: undefined, + qs: undefined, + url: 'http://localhost:11434/api/tags', + json: true, + }, + ); + }); + + it('should handle undefined parameters', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + }); + executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({}); + + await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags'); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'ollamaApi', + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + body: undefined, + qs: undefined, + url: 'http://localhost:11434/api/tags', + json: true, + }, + ); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/transport/index.ts new file mode 100644 index 00000000000..8380f24bfba --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Ollama/transport/index.ts @@ -0,0 +1,56 @@ +import type { + IDataObject, + IExecuteFunctions, + IHttpRequestMethods, + ILoadOptionsFunctions, +} from 'n8n-workflow'; + +type RequestParameters = { + headers?: IDataObject; + body?: IDataObject | string; + qs?: IDataObject; + option?: IDataObject; +}; + +export async function apiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + parameters?: RequestParameters, +) { + const { body, qs, option } = parameters ?? {}; + + const credentials = await this.getCredentials<{ + apiKey?: string; + baseUrl: string; + }>('ollamaApi'); + const apiKey = credentials.apiKey; + if (apiKey !== undefined && typeof apiKey !== 'string') { + throw new Error('API key must be a string'); + } + + const url = new URL(endpoint, credentials.baseUrl).toString(); + + const headers = parameters?.headers ?? {}; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const options = { + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + method, + body, + qs, + url, + json: true, + }; + + if (option && Object.keys(option).length !== 0) { + Object.assign(options, option); + } + + return await this.helpers.httpRequestWithAuthentication.call(this, 'ollamaApi', options); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/__tests__/modelFiltering.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/__tests__/modelFiltering.test.ts new file mode 100644 index 00000000000..e007b8dc946 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/__tests__/modelFiltering.test.ts @@ -0,0 +1,40 @@ +import { shouldIncludeModel } from '../modelFiltering'; + +describe('shouldIncludeModel', () => { + const testCases: Array<{ modelId: string; officialAPI: boolean }> = [ + // Excluded model types + { modelId: 'babbage-002', officialAPI: false }, + { modelId: 'davinci-002', officialAPI: false }, + { modelId: 'computer-use-preview', officialAPI: false }, + { modelId: 'dall-e-3', officialAPI: false }, + { modelId: 'text-embedding-ada-002', officialAPI: false }, + { modelId: 'tts-1', officialAPI: false }, + { modelId: 'whisper-1', officialAPI: false }, + { modelId: 'omni-moderation-latest', officialAPI: false }, + { modelId: 'sora-1', officialAPI: false }, + { modelId: 'gpt-4o-realtime-preview', officialAPI: false }, // infix check for -realtime + { modelId: 'gpt-3.5-turbo-instruct', officialAPI: false }, // gpt-* with instruct + + // Included models (standard chat models) + { modelId: 'gpt-4', officialAPI: true }, + { modelId: 'gpt-4o', officialAPI: true }, + { modelId: 'o1-preview', officialAPI: true }, + { modelId: 'ft:gpt-3.5-turbo', officialAPI: true }, // fine-tuned models + + // Edge cases + { modelId: 'llama-3-70b-instruct', officialAPI: true }, // non-gpt instruct is allowed + { modelId: 'custom-model', officialAPI: true }, // arbitrary custom model names + ]; + + describe('Custom API behavior', () => { + it.each(testCases)('should include "$modelId"', ({ modelId }) => { + expect(shouldIncludeModel(modelId, true)).toBe(true); + }); + }); + + describe('Official OpenAI API filtering', () => { + it.each(testCases)('$officialAPI: "$modelId"', ({ modelId, officialAPI }) => { + expect(shouldIncludeModel(modelId, false)).toBe(officialAPI); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/modelFiltering.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/modelFiltering.ts new file mode 100644 index 00000000000..1ffc2eace31 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/modelFiltering.ts @@ -0,0 +1,30 @@ +/** + * Determines whether a model should be included in the model list based on + * whether it's a custom API and the model's ID. + * + * @param modelId - The ID of the model to check + * @param isCustomAPI - Whether this is a custom API (not official OpenAI) + * @returns true if the model should be included, false otherwise + */ +export function shouldIncludeModel(modelId: string, isCustomAPI: boolean): boolean { + // For custom APIs, include all models + if (isCustomAPI) { + return true; + } + + // For official OpenAI API, exclude certain model types + return !( + modelId.startsWith('babbage') || + modelId.startsWith('davinci') || + modelId.startsWith('computer-use') || + modelId.startsWith('dall-e') || + modelId.startsWith('text-embedding') || + modelId.startsWith('tts') || + modelId.includes('-tts') || + modelId.startsWith('whisper') || + modelId.startsWith('omni-moderation') || + modelId.startsWith('sora') || + modelId.includes('-realtime') || + (modelId.startsWith('gpt-') && modelId.includes('instruct')) + ); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts index f1d66d7c8cc..7b4aba2bca4 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts @@ -7,6 +7,7 @@ import type { import type { Assistant } from 'openai/resources/beta/assistants'; import type { Model } from 'openai/resources/models'; +import { shouldIncludeModel } from '../helpers/modelFiltering'; import { apiRequest } from '../transport'; export async function fileSearch( @@ -77,22 +78,8 @@ export async function modelSearch( ): Promise { const credentials = await this.getCredentials<{ url: string }>('openAiApi'); const url = credentials.url && new URL(credentials.url); - const isCustomAPI = url && !['api.openai.com', 'ai-assistant.n8n.io'].includes(url.hostname); - return await getModelSearch( - (model) => - !isCustomAPI && - !( - model.id.startsWith('babbage') || - model.id.startsWith('davinci') || - model.id.startsWith('computer-use') || - model.id.startsWith('dall-e') || - model.id.startsWith('text-embedding') || - model.id.startsWith('tts') || - model.id.startsWith('whisper') || - model.id.startsWith('omni-moderation') || - (model.id.startsWith('gpt-') && model.id.includes('instruct')) - ), - )(this, filter); + const isCustomAPI = !!(url && !['api.openai.com', 'ai-assistant.n8n.io'].includes(url.hostname)); + return await getModelSearch((model) => shouldIncludeModel(model.id, isCustomAPI))(this, filter); } export async function imageModelSearch( diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 156e8ae37f5..78b5c07ed10 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.114.0", + "version": "1.115.0", "description": "", "main": "index.js", "scripts": { @@ -51,6 +51,7 @@ "nodes": [ "dist/nodes/vendors/Anthropic/Anthropic.node.js", "dist/nodes/vendors/GoogleGemini/GoogleGemini.node.js", + "dist/nodes/vendors/Ollama/Ollama.node.js", "dist/nodes/vendors/OpenAi/OpenAi.node.js", "dist/nodes/agents/Agent/Agent.node.js", "dist/nodes/agents/Agent/AgentTool.node.js", @@ -137,6 +138,7 @@ "dist/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.js", "dist/nodes/vector_store/VectorStorePineconeInsert/VectorStorePineconeInsert.node.js", "dist/nodes/vector_store/VectorStorePineconeLoad/VectorStorePineconeLoad.node.js", + "dist/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.js", "dist/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.js", "dist/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.js", "dist/nodes/vector_store/VectorStoreSupabaseInsert/VectorStoreSupabaseInsert.node.js", @@ -150,6 +152,7 @@ ] }, "devDependencies": { + "@n8n/eslint-plugin-community-nodes": "workspace:*", "@types/basic-auth": "catalog:", "@types/cheerio": "^0.22.15", "@types/html-to-text": "^9.0.1", @@ -161,7 +164,7 @@ "fast-glob": "catalog:", "jest-mock-extended": "^3.0.4", "n8n-core": "workspace:*", - "tsup": "catalog:" + "tsup": "^8.5.0" }, "dependencies": { "@aws-sdk/client-sso-oidc": "3.808.0", @@ -227,7 +230,7 @@ "pdf-parse": "1.1.1", "pg": "8.12.0", "proxy-from-env": "^1.1.0", - "redis": "4.6.12", + "redis": "4.6.14", "sanitize-html": "2.12.1", "sqlite3": "5.1.7", "temp": "0.9.4", diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 524e36b5388..c9bf87d19a8 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.38.0", + "version": "0.39.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/scan-community-package/package.json b/packages/@n8n/scan-community-package/package.json index 81aa247de7d..62945fd02c4 100644 --- a/packages/@n8n/scan-community-package/package.json +++ b/packages/@n8n/scan-community-package/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/scan-community-package", - "version": "0.6.0", + "version": "0.7.0", "description": "Static code analyser for n8n community packages", "license": "none", "bin": "scanner/cli.mjs", diff --git a/packages/@n8n/stylelint-config/src/rules/css-var-naming.test.ts b/packages/@n8n/stylelint-config/src/rules/css-var-naming.test.ts index 0ee5f14b2e0..f3fbb8e8a8b 100644 --- a/packages/@n8n/stylelint-config/src/rules/css-var-naming.test.ts +++ b/packages/@n8n/stylelint-config/src/rules/css-var-naming.test.ts @@ -22,9 +22,9 @@ describe('css-var-naming rule', () => { const namespacePattern = ` :root { --n8n--color--primary: #0d6efd; - --n8n--button--background--primary: #0d6efd; - --n8n--button--background--primary--hover: #0b5ed7; - --n8n--text-color--muted: #888; + --n8n--button--color--background--primary: #0d6efd; + --n8n--button--color--background--primary--hover: #0b5ed7; + --n8n--color--text--muted: #888; } `; const result = await lintCSS(namespacePattern); @@ -35,8 +35,8 @@ describe('css-var-naming rule', () => { const namespacePattern = ` :root { --chat--color--primary: #0d6efd; - --chat--button--background--primary: #0d6efd; - --chat--text-color--base: #333; + --chat--button--color--background--primary: #0d6efd; + --chat--color--text--base: #333; } `; const result = await lintCSS(namespacePattern); @@ -60,7 +60,7 @@ describe('css-var-naming rule', () => { const noNamespace = ` :root { --color--primary: #0d6efd; - --button--background--primary: #0d6efd; + --button--color--background--primary: #0d6efd; } `; const result = await lintCSS(noNamespace); @@ -73,7 +73,7 @@ describe('css-var-naming rule', () => { // Other first groups are treated as components, which is valid const componentFirst = ` :root { - --button--background--primary: #0d6efd; + --button--color--background--primary: #0d6efd; --tabs--tab--color--base: #333; } `; @@ -84,7 +84,7 @@ describe('css-var-naming rule', () => { it('should accept namespace with component and states', async () => { const complexNamespace = ` :root { - --n8n--button--background--primary--solid--hover: #0b5ed7; + --n8n--button--color--background--primary--solid--hover: #0b5ed7; --chat--input--border-color--primary--focus: blue; } `; @@ -95,7 +95,7 @@ describe('css-var-naming rule', () => { it('should accept namespace with all 8 groups', async () => { const maxGroups = ` :root { - --n8n--button--part--text-color--primary--solid--hover--dark: #000; + --n8n--button--part--color--text--primary--solid--hover--dark: #000; } `; const result = await lintCSS(maxGroups); @@ -108,8 +108,8 @@ describe('css-var-naming rule', () => { const validPatterns = ` :root { --color--primary: #0d6efd; - --text-color--muted: #5b6270; - --background--surface: #ffffff; + --color--text--muted: #5b6270; + --color--background--surface: #ffffff; --spacing--md: 20px; --font-size--lg: 18px; } @@ -127,7 +127,47 @@ describe('css-var-naming rule', () => { const result = await lintCSS(invalidPattern); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings[0]).toMatchObject({ - text: expect.stringContaining('Must follow pattern'), + text: expect.stringContaining('Must have at least 2 groups'), + }); + }); + + it('should reject properties without values', async () => { + const invalidPattern = ` + :root { + --color: #0d6efd; + --spacing: 4px; + } + `; + const result = await lintCSS(invalidPattern); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must have at least 2 groups'), + }); + }); + + it('should reject spacing property without value', async () => { + const invalidPattern = ` + :root { + --spacing: 4px; + } + `; + const result = await lintCSS(invalidPattern); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must have at least 2 groups'), + }); + }); + + it('should reject variable without proeprty', async () => { + const invalidPattern = ` + :root { + --button: 4px; + } + `; + const result = await lintCSS(invalidPattern); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must have at least 2 groups'), }); }); @@ -140,7 +180,7 @@ describe('css-var-naming rule', () => { const result = await lintCSS(invalidPattern); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings[0]).toMatchObject({ - text: expect.stringContaining('Must follow pattern'), + text: expect.stringContaining('Must have at least 2 groups'), }); }); @@ -164,10 +204,10 @@ describe('css-var-naming rule', () => { expect(result.warnings.length).toBeGreaterThan(0); }); - it('should reject patterns with more than 8 groups', async () => { + it('should reject patterns with more than 10 groups', async () => { const invalidPattern = ` :root { - --a--b--c--d--e--f--g--h--i: value; + --a--b--c--d--e--f--g--h--i--j--k: value; } `; const result = await lintCSS(invalidPattern); @@ -197,14 +237,16 @@ describe('css-var-naming rule', () => { const validProperties = ` :root { --color--primary: #0d6efd; - --text-color--base: #333; - --background--light: #fff; + --color--text--base: #333; + --color--background--light: #fff; + --color--foreground--light: #f5f5f5; --border-color--primary: #ddd; - --border-width--thin: 1px; + --border-left-width--thin: 1px; --icon-color--muted: #888; --radius--md: 4px; --shadow--sm: 0 1px 2px rgba(0,0,0,0.1); --spacing--lg: 24px; + --padding--lg: 24px; --font-size--md: 16px; --font-weight--bold: 600; --line-height--normal: 1.5; @@ -241,18 +283,238 @@ describe('css-var-naming rule', () => { const result = await lintCSS(invalidProperty); expect(result.warnings.length).toBeGreaterThan(0); }); + + it('should reject primary semantic value before property', async () => { + const invalidOrder = ` + :root { + --primary--color: #0d6efd; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject secondary semantic value before property', async () => { + const invalidOrder = ` + :root { + --secondary--color: #6c757d; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject success semantic value before property', async () => { + const invalidOrder = ` + :root { + --success--color: #28a745; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject danger semantic value before property', async () => { + const invalidOrder = ` + :root { + --danger--color: #dc3545; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject md scale value before property', async () => { + const invalidOrder = ` + :root { + --md--spacing: 20px; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject lg scale value before property', async () => { + const invalidOrder = ` + :root { + --lg--font-size: 18px; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject xl scale value before property', async () => { + const invalidOrder = ` + :root { + --xl--radius: 12px; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject component with primary value before color property', async () => { + const invalidOrder = ` + :root { + --button--primary--color: #0d6efd; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject component with surface value before color--background property', async () => { + const invalidOrder = ` + :root { + --card--surface--color--background: #fff; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject namespace with primary value before color property', async () => { + const invalidOrder = ` + :root { + --n8n--primary--color: #0d6efd; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject namespace with muted value before color--text property', async () => { + const invalidOrder = ` + :root { + --chat--muted--color--text: #888; + } + `; + const result = await lintCSS(invalidOrder); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject invalid single-dash color-text format', async () => { + const invalidFormat = ` + :root { + --color-text--primary: #0d6efd; + } + `; + const result = await lintCSS(invalidFormat); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must include a valid property from vocabulary'), + }); + }); + + it('should reject invalid single-dash color-background format', async () => { + const invalidFormat = ` + :root { + --color-background--surface: #fff; + } + `; + const result = await lintCSS(invalidFormat); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must include a valid property from vocabulary'), + }); + }); + + it('should reject invalid single-dash color-foreground format', async () => { + const invalidFormat = ` + :root { + --color-foreground--light: #f5f5f5; + } + `; + const result = await lintCSS(invalidFormat); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must include a valid property from vocabulary'), + }); + }); + + it('should reject component with invalid single-dash color-text format', async () => { + const invalidFormat = ` + :root { + --button--color-text--primary: #0d6efd; + } + `; + const result = await lintCSS(invalidFormat); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must include a valid property from vocabulary'), + }); + }); + + it('should reject component with invalid single-dash color-background format', async () => { + const invalidFormat = ` + :root { + --card--color-background--surface: #fff; + } + `; + const result = await lintCSS(invalidFormat); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('Must include a valid property from vocabulary'), + }); + }); + + it('should accept valid double-dash format for color properties', async () => { + const validFormat = ` + :root { + --color--text--primary: #0d6efd; + --color--background--surface: #fff; + --color--foreground--light: #f5f5f5; + } + `; + const result = await lintCSS(validFormat); + expect(result.warnings).toHaveLength(0); + }); }); describe('component tokens', () => { it('should accept component-level tokens', async () => { const componentTokens = ` :root { - --button--background--primary: #0d6efd; - --button--text-color--on-primary: #fff; + --button--color--background--primary: #0d6efd; + --button--color--text--on-primary: #fff; --button--border-color--outline: #ddd; --card--radius--md: 8px; - --tabs--tab--text-color--muted: #888; - --select--menu--background--dark: #000; + --tabs--tab--color--text--muted: #888; + --select--menu--color--background--dark: #000; --tooltip--arrow--color--primary: #333; } `; @@ -263,7 +525,7 @@ describe('css-var-naming rule', () => { it('should accept component with part tokens', async () => { const componentPartTokens = ` :root { - --tabs--tab--background--surface: #fff; + --tabs--tab--color--background--surface: #fff; --select--menu--shadow--lg: 0 4px 8px rgba(0,0,0,0.1); --tooltip--arrow--border-color--primary: #ddd; } @@ -271,22 +533,59 @@ describe('css-var-naming rule', () => { const result = await lintCSS(componentPartTokens); expect(result.warnings).toHaveLength(0); }); + + it('should reject callout with secondary before icon-color property', async () => { + const invalidCalloutTokens = ` + :root { + --callout--secondary--icon-color: #0d6efd; + } + `; + const result = await lintCSS(invalidCalloutTokens); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should reject callout with secondary before color--text property', async () => { + const invalidCalloutTokens = ` + :root { + --callout--secondary--color--text: #888; + } + `; + const result = await lintCSS(invalidCalloutTokens); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('appears before the property'), + }); + }); + + it('should accept callout component tokens with property before value', async () => { + const validCalloutTokens = ` + :root { + --callout--icon-color--secondary: #0d6efd; + --callout--color--text--secondary: #888; + } + `; + const result = await lintCSS(validCalloutTokens); + expect(result.warnings).toHaveLength(0); + }); }); describe('states validation', () => { it('should accept valid state modifiers', async () => { const stateModifiers = ` :root { - --button--background--primary--hover: #0b5ed7; - --button--background--primary--active: #0a58ca; - --button--background--primary--focus: #0d6efd; - --button--background--primary--focus-visible: #0d6efd; - --button--background--primary--disabled: #ccc; + --button--color--background--primary--hover: #0b5ed7; + --button--color--background--primary--active: #0a58ca; + --button--color--background--primary--focus: #0d6efd; + --button--color--background--primary--focus-visible: #0d6efd; + --button--color--background--primary--disabled: #ccc; --input--border-color--primary--invalid: red; - --checkbox--background--primary--checked: #0d6efd; - --select--background--surface--opened: #fff; - --accordion--background--surface--closed: #f5f5f5; - --button--background--primary--loading: #999; + --checkbox--color--background--primary--checked: #0d6efd; + --select--color--background--surface--opened: #fff; + --accordion--color--background--surface--closed: #f5f5f5; + --button--color--background--primary--loading: #999; } `; const result = await lintCSS(stateModifiers); @@ -296,8 +595,8 @@ describe('css-var-naming rule', () => { it('should accept link-specific states', async () => { const linkStates = ` :root { - --link--text-color--primary--visited: purple; - --link--text-color--primary--hover: blue; + --link--color--text--primary--visited: purple; + --link--color--text--primary--hover: blue; } `; const result = await lintCSS(linkStates); @@ -309,12 +608,12 @@ describe('css-var-naming rule', () => { it('should accept valid variant modifiers', async () => { const variantModifiers = ` :root { - --button--background--primary--solid: #0d6efd; - --button--background--primary--outline: transparent; - --button--background--primary--ghost: transparent; - --button--background--primary--link: transparent; - --button--background--primary--soft: #e7f1ff; - --button--background--primary--subtle: #f0f8ff; + --button--color--background--primary--solid: #0d6efd; + --button--color--background--primary--outline: transparent; + --button--color--background--primary--ghost: transparent; + --button--color--background--primary--link: transparent; + --button--color--background--primary--soft: #e7f1ff; + --button--color--background--primary--subtle: #f0f8ff; } `; const result = await lintCSS(variantModifiers); @@ -324,9 +623,9 @@ describe('css-var-naming rule', () => { it('should accept variants with states (variant before state)', async () => { const variantWithState = ` :root { - --button--background--primary--solid--hover: #0b5ed7; - --button--background--primary--outline--active: #0a58ca; - --button--background--primary--ghost--focus: rgba(13, 110, 253, 0.1); + --button--color--background--primary--solid--hover: #0b5ed7; + --button--color--background--primary--outline--active: #0a58ca; + --button--color--background--primary--ghost--focus: rgba(13, 110, 253, 0.1); } `; const result = await lintCSS(variantWithState); @@ -336,7 +635,7 @@ describe('css-var-naming rule', () => { it('should reject when state comes before variant', async () => { const invalidOrder = ` :root { - --button--background--primary--hover--solid: #0b5ed7; + --button--color--background--primary--hover--solid: #0b5ed7; } `; const result = await lintCSS(invalidOrder); @@ -353,9 +652,9 @@ describe('css-var-naming rule', () => { :root { --color--primary--dark: #66a3ff; --color--primary--light: #0d6efd; - --background--surface--dark: #000; - --background--surface--light: #fff; - --text-color--base--hc: #000; + --color--background--surface--dark: #000; + --color--background--surface--light: #fff; + --color--text--base--hc: #000; --spacing--md--rtl: 20px; --color--primary--print: #000; } @@ -404,14 +703,24 @@ describe('css-var-naming rule', () => { it('should accept scale values', async () => { const scaleValues = ` :root { - --spacing--2xs: 2px; - --spacing--xs: 4px; - --spacing--sm: 8px; - --spacing--md: 16px; + --spacing--5xs: 2px; + --spacing--4xs: 4px; + --spacing--3xs: 6px; + --spacing--2xs: 8px; + --spacing--xs: 12px; + --spacing--sm: 16px; + --spacing--md: 20px; --spacing--lg: 24px; --spacing--xl: 32px; --spacing--2xl: 48px; --spacing--3xl: 64px; + --spacing--4xl: 128px; + --spacing--5xl: 256px; + --font-size--5xs: 8px; + --font-size--4xs: 9px; + --font-size--3xs: 10px; + --font-size--2xs: 12px; + --font-size--xs: 13px; --radius--none: 0; --radius--sm: 2px; --radius--md: 4px; @@ -429,24 +738,52 @@ describe('css-var-naming rule', () => { expect(result.warnings).toHaveLength(0); }); - it('should accept descriptive value names (4+ chars)', async () => { + it('should accept descriptive value names (3+ chars)', async () => { const descriptiveValues = ` :root { --color--purple: #800080; - --text-color--base: #333; - --border-width--thin: 1px; + --color--text--base: #333; + --border-left-width--thin: 1px; --z--modal: 1000; --duration--fast: 200ms; + --spacing--med: 20px; } `; const result = await lintCSS(descriptiveValues); expect(result.warnings).toHaveLength(0); }); - it('should reject very short value names (<4 chars)', async () => { + it('should accept font-weight specific values only with font-weight property', async () => { + const fontWeightValues = ` + :root { + --font-weight--regular: 400; + --font-weight--medium: 500; + --font-weight--semibold: 600; + --font-weight--bold: 700; + --button--font-weight--bold: 700; + } + `; + const result = await lintCSS(fontWeightValues); + expect(result.warnings).toHaveLength(0); + }); + + it('should reject font-weight specific values with non-font-weight properties', async () => { + const invalidFontWeight = ` + :root { + --spacing--bold: 20px; + } + `; + const result = await lintCSS(invalidFontWeight); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('can only be used with font-weight property'), + }); + }); + + it('should reject very short value names (<3 chars)', async () => { const shortValue = ` :root { - --spacing--xxx: 20px; + --spacing--xx: 20px; } `; const result = await lintCSS(shortValue); @@ -471,21 +808,202 @@ describe('css-var-naming rule', () => { it('should accept component names as values', async () => { const componentValues = ` :root { - --button--background--surface: #fff; - --tooltip--text-color--on-surface: #000; + --button--color--background--surface: #fff; + --tooltip--color--text--on-surface: #000; } `; const result = await lintCSS(componentValues); expect(result.warnings).toHaveLength(0); }); + + it('should accept HSL color component values (h, s, l)', async () => { + const hslValues = ` + :root { + --color--primary--h: 220; + --color--primary--s: 90%; + --color--primary--l: 50%; + --color--background--surface--h: 0; + --color--background--surface--s: 0%; + --color--background--surface--l: 100%; + } + `; + const result = await lintCSS(hslValues); + expect(result.warnings).toHaveLength(0); + }); + + it('should accept HSL color components with component names', async () => { + const hslComponentValues = ` + :root { + --node-type--color--background--l: 50%; + --node-type--color--h: 220; + --button--color--background--s: 90%; + } + `; + const result = await lintCSS(hslComponentValues); + expect(result.warnings).toHaveLength(0); + }); + + it('should reject HSL component h in middle position', async () => { + const invalidHsl = ` + :root { + --color--h--primary: 220; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject HSL component s in middle position', async () => { + const invalidHsl = ` + :root { + --color--s--primary: 90%; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject HSL component l in middle position', async () => { + const invalidHsl = ` + :root { + --color--l--primary: 50%; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject HSL component in middle with component name', async () => { + const invalidHsl = ` + :root { + --button--color--h--primary: 220; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject HSL component in middle with state', async () => { + const invalidHsl = ` + :root { + --button--color--l--primary--hover: 50%; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should accept HSL components at the end with states', async () => { + const validHsl = ` + :root { + --button--color--background--primary--hover--l: 50%; + --button--color--background--primary--h: 220; + } + `; + const result = await lintCSS(validHsl); + expect(result.warnings).toHaveLength(0); + }); + + it('should reject group ending with -h in middle position', async () => { + const invalidHsl = ` + :root { + --color--primary-h--base: 220; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject group ending with -s in middle position', async () => { + const invalidHsl = ` + :root { + --color--primary-s--base: 90%; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject group ending with -l in middle position', async () => { + const invalidHsl = ` + :root { + --color--primary-l--base: 50%; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject component with group ending with -h in middle', async () => { + const invalidHsl = ` + :root { + --button--color--primary-h--hover: 220; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject group ending with -h at the end', async () => { + const invalidHsl = ` + :root { + --color--primary-h: 220; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); + + it('should reject component with group ending with -h at the end', async () => { + const invalidHsl = ` + :root { + --button--color--primary-h: 220; + } + `; + const result = await lintCSS(invalidHsl); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatchObject({ + text: expect.stringContaining('HSL component'), + }); + }); }); describe('var() references validation', () => { it('should validate CSS variables in var() references', async () => { const varReferences = ` .button { - background: var(--button--background--primary); - color: var(--button--text-color--on-primary); + background: var(--button--color--background--primary); + color: var(--button--color--text--on-primary); border-color: var(--button--border-color--outline); } `; @@ -496,13 +1014,13 @@ describe('css-var-naming rule', () => { it('should reject invalid CSS variables in var() references', async () => { const invalidVarReferences = ` .button { - background: var(--button-background); + background: var(--button-color--background); } `; const result = await lintCSS(invalidVarReferences); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings[0]).toMatchObject({ - text: expect.stringContaining('Must follow pattern'), + text: expect.stringContaining('Must include a valid property'), }); }); @@ -522,7 +1040,7 @@ describe('css-var-naming rule', () => { it('should reject invalid CSS variables with mixed order in var() references', async () => { const invalidVarReferences = ` .button { - background: var(--button--background--primary--hover--solid); + background: var(--button--color--background--primary--hover--solid); } `; const result = await lintCSS(invalidVarReferences); @@ -535,7 +1053,7 @@ describe('css-var-naming rule', () => { it('should accept var() with fallback values', async () => { const varWithFallback = ` .button { - background: var(--button--background--primary, var(--color--primary)); + background: var(--button--color--background--primary, var(--color--primary)); border-radius: var(--button--radius--md, var(--radius--md)); } `; @@ -548,15 +1066,15 @@ describe('css-var-naming rule', () => { it('should accept all examples from proposal section 6', async () => { const proposalExamples = ` .button { - background: var(--button--background--primary); - color: var(--button--text-color--on-primary); + background: var(--button--color--background--primary); + color: var(--button--color--text--on-primary); border-color: var(--button--border-color--ghost); border-radius: var(--button--radius--md, var(--radius--md)); box-shadow: var(--button--shadow--sm, var(--shadow--sm)); } .button:hover { - background: var(--button--background--primary--hover); + background: var(--button--color--background--primary--hover); } .input:focus-visible { @@ -564,7 +1082,7 @@ describe('css-var-naming rule', () => { } .card { - background: var(--card--background--surface, var(--color--surface)); + background: var(--card--color--background--surface, var(--color--surface)); box-shadow: var(--card--shadow--lg, var(--shadow--lg)); } `; @@ -577,15 +1095,15 @@ describe('css-var-naming rule', () => { :root { --color--primary: #0d6efd; --color--surface: #ffffff; - --text-color--muted: #5b6270; - --button--background--primary: var(--color--primary); - --button--text-color--on-primary: #ffffff; + --color--text--muted: #5b6270; + --button--color--background--primary: var(--color--primary); + --button--color--text--on-primary: #ffffff; } :root[data-theme="dark"] { --color--primary: #66a3ff; --color--surface: #0f1115; - --text-color--muted: #9aa3b2; + --color--text--muted: #9aa3b2; } `; const result = await lintCSS(themingExample); @@ -594,6 +1112,39 @@ describe('css-var-naming rule', () => { }); describe('edge cases', () => { + it('should accept single-property shorthand variables', async () => { + const singleProperties = ` + :root { + --shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + --radius: 4px; + --border-color: #ddd; + --border-style: solid; + --border-width: 1px; + --border: 1px solid #ddd; + --font-family: InterVariable, sans-serif; + } + `; + const result = await lintCSS(singleProperties); + expect(result.warnings).toHaveLength(0); + }); + + it('should accept shorthand properties in component patterns', async () => { + const componentShorthand = ` + :root { + --n8n--button--border-color: #ddd; + --button--border: 1px solid #ddd; + --chat--font--font-family: InterVariable, sans-serif; + --menu--tab--radius: 4px; + --card--shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + --input--border-style: solid; + --input--border-left-width: 1px; + --button--padding--md: 12px; + } + `; + const result = await lintCSS(componentShorthand); + expect(result.warnings).toHaveLength(0); + }); + it('should accept variables with numbers in groups', async () => { const numbersInGroups = ` :root { @@ -611,7 +1162,7 @@ describe('css-var-naming rule', () => { it('should accept kebab-case within groups', async () => { const kebabCase = ` :root { - --text-color--on-primary: #fff; + --color--text--on-primary: #fff; --outline-color--focus-visible: blue; --font-weight--semi-bold: 600; } @@ -634,7 +1185,7 @@ describe('css-var-naming rule', () => { it('should accept maximum valid pattern (8 groups)', async () => { const maximumPattern = ` :root { - --namespace--component--part--text-color--primary--solid--hover--dark: #000; + --namespace--component--part--color--text--primary--solid--hover--dark: #000; } `; const result = await lintCSS(maximumPattern); diff --git a/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts b/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts index 54015e4d11a..a1e9c68bbae 100644 --- a/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts +++ b/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts @@ -12,19 +12,38 @@ const meta = { }; // Reserved vocabulary from proposal.md +// NOTE: color--text, color--background, color--foreground use double dashes +// to separate "color" from the subtype (text/background/foreground) const PROPERTY_VOCABULARY = new Set([ 'color', - 'text-color', - 'background', + 'color--text', + 'color--background', + 'color--foreground', 'border-color', 'border-width', + 'border-top-color', + 'border-bottom-color', + 'border-right-color', + 'border-left-width', + 'border-style', + 'border', + 'height', 'icon-color', 'radius', + 'size', + 'stroke-width', 'shadow', 'spacing', + 'padding', 'font-size', 'font-weight', + 'font-family', 'line-height', + 'margin', + 'margin-right', + 'margin-left', + 'margin-top', + 'margin-bottom', 'z', 'duration', 'easing', @@ -32,6 +51,17 @@ const PROPERTY_VOCABULARY = new Set([ 'outline-width', ]); +// Properties that can be used as standalone single-group variables (without a value) +const STANDALONE_PROPERTIES = new Set([ + 'shadow', + 'radius', + 'border-color', + 'border-style', + 'border-width', + 'border', + 'font-family', +]); + const STATES = new Set([ 'hover', 'active', @@ -71,7 +101,9 @@ const SEMANTIC_VALUES = new Set([ ]); const SCALE_VALUES = new Set([ - 'none', + '5xs', + '4xs', + '3xs', '2xs', 'xs', 'sm', @@ -80,16 +112,16 @@ const SCALE_VALUES = new Set([ 'xl', '2xl', '3xl', - 'pill', - 'full', - 'regular', - 'medium', - 'semibold', - 'bold', + '4xl', + '5xl', ]); +// Font weight specific values (only valid with font-weight property) +const FONT_WEIGHT_VALUES = new Set(['regular', 'medium', 'semibold', 'bold']); + // Regex for basic validation -const BASIC_PATTERN = /^--[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*){1,7}$/; +// Allows 2-10 groups to accommodate double-dash properties like color--text +const BASIC_PATTERN = /^--[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*){1,9}$/; interface ValidationResult { valid: boolean; @@ -97,7 +129,23 @@ interface ValidationResult { } function validateCssVariable(variable: string): ValidationResult { - // Basic pattern check + // Split into groups first (drop first empty element from leading --) + const groups = variable.slice(2).split('--'); + + // Check if this is a single-group variable (e.g., --shadow, --radius, --border-color) + if (groups.length === 1) { + const singleGroup = groups[0]; + // Allow standalone properties that are in the STANDALONE_PROPERTIES set + if (STANDALONE_PROPERTIES.has(singleGroup)) { + return { valid: true }; + } + return { + valid: false, + reason: 'Must have at least 2 groups separated by double dashes (--property--value minimum)', + }; + } + + // Basic pattern check for multi-group variables if (!BASIC_PATTERN.test(variable)) { return { valid: false, @@ -106,10 +154,7 @@ function validateCssVariable(variable: string): ValidationResult { }; } - // Split into groups (drop first empty element from leading --) - const groups = variable.slice(2).split('--'); - - // Check group count (2-8 groups) + // Check group count (2-10 groups to accommodate double-dash properties like color--text) if (groups.length < 2) { return { valid: false, @@ -117,10 +162,10 @@ function validateCssVariable(variable: string): ValidationResult { }; } - if (groups.length > 8) { + if (groups.length > 10) { return { valid: false, - reason: 'Must have at most 8 groups (too many segments)', + reason: 'Must have at most 10 groups (too many segments)', }; } @@ -158,10 +203,65 @@ function validateCssVariable(variable: string): ValidationResult { .findIndex((group) => PROPERTY_VOCABULARY.has(group)); const absolutePropertyIndex = startIndex + propertyIndex; + // Check if any semantic or scale values appear before the property + const groupsBeforeProperty = groups.slice(startIndex, absolutePropertyIndex); + for (const group of groupsBeforeProperty) { + // Check if this group is a semantic value, scale value, or font-weight value + if (SEMANTIC_VALUES.has(group) || SCALE_VALUES.has(group) || FONT_WEIGHT_VALUES.has(group)) { + return { + valid: false, + reason: `Value "${group}" appears before the property. Values must come after the property (e.g., --color--${group}, not --${group}--color)`, + }; + } + } + + // Get the property name to validate specific property-value combinations + const propertyName = groups[absolutePropertyIndex]; + + // Check if HSL components (h, s, l) appear in non-final positions or as suffixes + const hslComponents = new Set(['h', 's', 'l']); + + // Check all groups after property for HSL-related issues + for (let i = absolutePropertyIndex + 1; i < groups.length; i++) { + const group = groups[i]; + const isLastGroup = i === groups.length - 1; + + // Check if group is exactly h, s, or l (allowed only at the end) + if (hslComponents.has(group)) { + if (!isLastGroup) { + return { + valid: false, + reason: `HSL component "${group}" must be at the end of the variable name (e.g., --color--primary--${group}, not --color--${group}--primary)`, + }; + } + // If it's the last group and exactly h/s/l, it's valid + continue; + } + + // Check if group ends with -h, -s, or -l (never allowed) + if (group.endsWith('-h') || group.endsWith('-s') || group.endsWith('-l')) { + return { + valid: false, + reason: `HSL component suffix in "${group}" is not allowed. Use standalone HSL components instead (e.g., --color--primary--h, not --color--primary-h)`, + }; + } + } + // The group after property should be a value (semantic or scale) if (absolutePropertyIndex + 1 < groups.length) { const valueGroup = groups[absolutePropertyIndex + 1]; + // Check if this is a font-weight specific value + if (FONT_WEIGHT_VALUES.has(valueGroup)) { + // Font weight values are only valid with font-weight property + if (propertyName !== 'font-weight') { + return { + valid: false, + reason: `Value "${valueGroup}" can only be used with font-weight property (e.g., --font-weight--${valueGroup})`, + }; + } + } + // Check if this is a known modifier (variant, state, mode, media) const isModifier = VARIANTS.has(valueGroup) || @@ -175,10 +275,13 @@ function validateCssVariable(variable: string): ValidationResult { const isValidValue = SEMANTIC_VALUES.has(valueGroup) || SCALE_VALUES.has(valueGroup) || + FONT_WEIGHT_VALUES.has(valueGroup) || // Allow color shades like "primary-500", "shade-50", "tint-50" /^[a-z]+-\d+$/.test(valueGroup) || - // Allow descriptive names (4+ chars) - these are likely intentional semantic names - valueGroup.length >= 4; + // Allow descriptive names (3+ chars) - these are likely intentional semantic names + valueGroup.length >= 3 || + // Support hsl css variables (only allowed at the end, checked above) + hslComponents.has(valueGroup); if (!isValidValue) { return { diff --git a/packages/@n8n/task-runner-python/.python-version b/packages/@n8n/task-runner-python/.python-version index 24ee5b1be99..6324d401a06 100644 --- a/packages/@n8n/task-runner-python/.python-version +++ b/packages/@n8n/task-runner-python/.python-version @@ -1 +1 @@ -3.13 +3.14 diff --git a/packages/@n8n/task-runner-python/justfile b/packages/@n8n/task-runner-python/justfile index ee65c1c4cde..7cc12c6e085 100644 --- a/packages/@n8n/task-runner-python/justfile +++ b/packages/@n8n/task-runner-python/justfile @@ -36,6 +36,17 @@ test-v: typecheck: uv run ty check src/ -# For debugging only, start the runner with a manually fetched grant token. +# For debugging only, start the runner with a manually fetched grant token. If no broker, wait until available. debug: - GRANT_TOKEN=$(curl -s -X POST http://127.0.0.1:5679/runners/auth -H "Content-Type: application/json" -d '{"token":"test"}' | jq -r '.data.token') && N8N_RUNNERS_GRANT_TOKEN="$GRANT_TOKEN" just run + #!/usr/bin/env bash + for i in {1..30}; do + GRANT_TOKEN=$(curl -s -X POST http://127.0.0.1:5679/runners/auth -H "Content-Type: application/json" -d '{"token":"test"}' | jq -r '.data.token') + if [ -n "$GRANT_TOKEN" ] && [ "$GRANT_TOKEN" != "null" ]; then + N8N_RUNNERS_GRANT_TOKEN="$GRANT_TOKEN" just run + exit 0 + fi + [ $i -eq 1 ] && echo "Waiting for n8n task broker server at http://127.0.0.1:5679..." + sleep 1 + done + echo "Error: Could not connect to n8n task broker server after 30 seconds" + exit 1 diff --git a/packages/@n8n/task-runner-python/src/config/security_config.py b/packages/@n8n/task-runner-python/src/config/security_config.py new file mode 100644 index 00000000000..e9373c31e60 --- /dev/null +++ b/packages/@n8n/task-runner-python/src/config/security_config.py @@ -0,0 +1,9 @@ +from typing import Set +from dataclasses import dataclass + + +@dataclass +class SecurityConfig: + stdlib_allow: Set[str] + external_allow: Set[str] + builtins_deny: set[str] diff --git a/packages/@n8n/task-runner-python/src/constants.py b/packages/@n8n/task-runner-python/src/constants.py index 763c7f9828d..89a9ae1f681 100644 --- a/packages/@n8n/task-runner-python/src/constants.py +++ b/packages/@n8n/task-runner-python/src/constants.py @@ -43,6 +43,7 @@ EXECUTOR_ALL_ITEMS_FILENAME = "" EXECUTOR_PER_ITEM_FILENAME = "" EXECUTOR_FILENAMES = {EXECUTOR_ALL_ITEMS_FILENAME, EXECUTOR_PER_ITEM_FILENAME} SIGTERM_EXIT_CODE = -15 +SIGKILL_EXIT_CODE = -9 # Broker DEFAULT_TASK_BROKER_URI = "http://127.0.0.1:5679" @@ -106,7 +107,8 @@ TASK_REJECTED_REASON_AT_CAPACITY = "No open task slots - runner already at capac # Security BUILTINS_DENY_DEFAULT = "eval,exec,compile,open,input,breakpoint,getattr,object,type,vars,setattr,delattr,hasattr,dir,memoryview,__build_class__,globals,locals" -ALWAYS_BLOCKED_ATTRIBUTES = { +BLOCKED_ATTRIBUTES = { + # runtime attributes "__subclasses__", "__globals__", "__builtins__", @@ -130,11 +132,8 @@ ALWAYS_BLOCKED_ATTRIBUTES = { "ag_code", "__thisclass__", "__self_class__", -} -# Attributes blocked only in certain contexts: -# - In attribute chains (e.g., x.__class__.__bases__) -# - On literals (e.g., "".__class__) -CONDITIONALLY_BLOCKED_ATTRIBUTES = { + # introspection attributes + "__base__", "__class__", "__bases__", "__code__", @@ -152,8 +151,8 @@ CONDITIONALLY_BLOCKED_ATTRIBUTES = { "__func__", "__wrapped__", "__annotations__", + "__spec__", } -UNSAFE_ATTRIBUTES = ALWAYS_BLOCKED_ATTRIBUTES | CONDITIONALLY_BLOCKED_ATTRIBUTES # errors ERROR_RELATIVE_IMPORT = "Relative imports are disallowed." diff --git a/packages/@n8n/task-runner-python/src/errors/__init__.py b/packages/@n8n/task-runner-python/src/errors/__init__.py index 8ac7c295e5c..1d46233380d 100644 --- a/packages/@n8n/task-runner-python/src/errors/__init__.py +++ b/packages/@n8n/task-runner-python/src/errors/__init__.py @@ -2,9 +2,10 @@ from .configuration_error import ConfigurationError from .no_idle_timeout_handler_error import NoIdleTimeoutHandlerError from .security_violation_error import SecurityViolationError from .task_cancelled_error import TaskCancelledError +from .task_killed_error import TaskKilledError from .task_missing_error import TaskMissingError from .task_result_missing_error import TaskResultMissingError -from .task_process_exit_error import TaskProcessExitError +from .task_subprocess_failed_error import TaskSubprocessFailedError from .task_runtime_error import TaskRuntimeError from .task_timeout_error import TaskTimeoutError from .websocket_connection_error import WebsocketConnectionError @@ -14,8 +15,9 @@ __all__ = [ "NoIdleTimeoutHandlerError", "SecurityViolationError", "TaskCancelledError", + "TaskKilledError", "TaskMissingError", - "TaskProcessExitError", + "TaskSubprocessFailedError", "TaskResultMissingError", "TaskRuntimeError", "TaskTimeoutError", diff --git a/packages/@n8n/task-runner-python/src/errors/task_killed_error.py b/packages/@n8n/task-runner-python/src/errors/task_killed_error.py new file mode 100644 index 00000000000..f78c7acd92d --- /dev/null +++ b/packages/@n8n/task-runner-python/src/errors/task_killed_error.py @@ -0,0 +1,11 @@ +class TaskKilledError(Exception): + """Raised when a task process is forcefully killed (SIGKILL). + + This usually indicates: + - Out of memory (OOM killer) + - Process exceeded resource limits + - Manual operator intervention + """ + + def __init__(self): + super().__init__("Process was forcefully killed (SIGKILL)") diff --git a/packages/@n8n/task-runner-python/src/errors/task_process_exit_error.py b/packages/@n8n/task-runner-python/src/errors/task_process_exit_error.py deleted file mode 100644 index f3657b80765..00000000000 --- a/packages/@n8n/task-runner-python/src/errors/task_process_exit_error.py +++ /dev/null @@ -1,6 +0,0 @@ -class TaskProcessExitError(Exception): - """Raised when a task subprocess exits with a non-zero exit code.""" - - def __init__(self, exit_code: int): - super().__init__(f"Process exited with code {exit_code}") - self.exit_code = exit_code diff --git a/packages/@n8n/task-runner-python/src/errors/task_runtime_error.py b/packages/@n8n/task-runner-python/src/errors/task_runtime_error.py index b08191b6604..32fea8056f2 100644 --- a/packages/@n8n/task-runner-python/src/errors/task_runtime_error.py +++ b/packages/@n8n/task-runner-python/src/errors/task_runtime_error.py @@ -8,4 +8,6 @@ class TaskRuntimeError(Exception): message = error_dict["message"] super().__init__(message) self.stack_trace = error_dict.get("stack", "") - self.description = error_dict.get("stderr", "") + self.description = error_dict.get("description", "") or error_dict.get( + "stderr", "" + ) diff --git a/packages/@n8n/task-runner-python/src/errors/task_subprocess_failed_error.py b/packages/@n8n/task-runner-python/src/errors/task_subprocess_failed_error.py new file mode 100644 index 00000000000..604107ddb42 --- /dev/null +++ b/packages/@n8n/task-runner-python/src/errors/task_subprocess_failed_error.py @@ -0,0 +1,6 @@ +class TaskSubprocessFailedError(Exception): + """Raised when a task subprocess exits with a non-zero exit code, excluding SIGTERM and SIGKILL.""" + + def __init__(self, exit_code: int): + super().__init__(f"Task subprocess exited with code {exit_code}") + self.exit_code = exit_code diff --git a/packages/@n8n/task-runner-python/src/import_validation.py b/packages/@n8n/task-runner-python/src/import_validation.py new file mode 100644 index 00000000000..7cef273548c --- /dev/null +++ b/packages/@n8n/task-runner-python/src/import_validation.py @@ -0,0 +1,38 @@ +import sys +from typing import Optional, Tuple + +from src.config.security_config import SecurityConfig +from src.constants import ERROR_STDLIB_DISALLOWED, ERROR_EXTERNAL_DISALLOWED + + +def validate_module_import( + module_path: str, + security_config: SecurityConfig, +) -> Tuple[bool, Optional[str]]: + stdlib_allow = security_config.stdlib_allow + external_allow = security_config.external_allow + + module_name = module_path.split(".")[0] + is_stdlib = module_name in sys.stdlib_module_names + is_external = not is_stdlib + + if is_stdlib and ("*" in stdlib_allow or module_name in stdlib_allow): + return (True, None) + + if is_external and ("*" in external_allow or module_name in external_allow): + return (True, None) + + if is_stdlib: + stdlib_allowed_str = ", ".join(sorted(stdlib_allow)) if stdlib_allow else "none" + error_msg = ERROR_STDLIB_DISALLOWED.format( + module=module_path, allowed=stdlib_allowed_str + ) + else: + external_allowed_str = ( + ", ".join(sorted(external_allow)) if external_allow else "none" + ) + error_msg = ERROR_EXTERNAL_DISALLOWED.format( + module=module_path, allowed=external_allowed_str + ) + + return (False, error_msg) diff --git a/packages/@n8n/task-runner-python/src/task_analyzer.py b/packages/@n8n/task-runner-python/src/task_analyzer.py index 73b4b1f08d5..df547168af3 100644 --- a/packages/@n8n/task-runner-python/src/task_analyzer.py +++ b/packages/@n8n/task-runner-python/src/task_analyzer.py @@ -1,19 +1,17 @@ import ast import hashlib -import sys from typing import Set, Tuple from collections import OrderedDict from src.errors import SecurityViolationError +from src.import_validation import validate_module_import +from src.config.security_config import SecurityConfig from src.constants import ( MAX_VALIDATION_CACHE_SIZE, ERROR_RELATIVE_IMPORT, - ERROR_STDLIB_DISALLOWED, - ERROR_EXTERNAL_DISALLOWED, ERROR_DANGEROUS_ATTRIBUTE, ERROR_DYNAMIC_IMPORT, - ALWAYS_BLOCKED_ATTRIBUTES, - UNSAFE_ATTRIBUTES, + BLOCKED_ATTRIBUTES, ) CacheKey = Tuple[str, Tuple] # (code_hash, allowlists_tuple) @@ -24,16 +22,10 @@ ValidationCache = OrderedDict[CacheKey, CachedViolations] class SecurityValidator(ast.NodeVisitor): """AST visitor that enforces import allowlists and blocks dangerous attribute access.""" - def __init__(self, stdlib_allow: Set[str], external_allow: Set[str]): + def __init__(self, security_config: SecurityConfig): self.checked_modules: Set[str] = set() self.violations: list[str] = [] - - self.stdlib_allow = stdlib_allow - self.external_allow = external_allow - self._stdlib_allowed_str = self._format_allowed(stdlib_allow) - self._external_allowed_str = self._format_allowed(external_allow) - self._stdlib_allow_all = "*" in stdlib_allow - self._external_allow_all = "*" in external_allow + self.security_config = security_config # ========== Detection ========== @@ -58,17 +50,10 @@ class SecurityValidator(ast.NodeVisitor): def visit_Attribute(self, node: ast.Attribute) -> None: """Detect access to unsafe attributes that could bypass security restrictions.""" - if node.attr in UNSAFE_ATTRIBUTES: - # Block regardless of context - if node.attr in ALWAYS_BLOCKED_ATTRIBUTES: - self._add_violation( - node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=node.attr) - ) - # Block in attribute chains (e.g., x.__class__.__bases__) or on literals (e.g., "".__class__) - elif isinstance(node.value, (ast.Attribute, ast.Constant)): - self._add_violation( - node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=node.attr) - ) + if node.attr in BLOCKED_ATTRIBUTES: + self._add_violation( + node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=node.attr) + ) self.generic_visit(node) @@ -101,6 +86,25 @@ class SecurityValidator(ast.NodeVisitor): self.generic_visit(node) + def visit_Subscript(self, node: ast.Subscript) -> None: + """Detect dict access to blocked attributes, e.g. __builtins__['__spec__']""" + + is_builtins_access = ( + # __builtins__['__spec__'] + (isinstance(node.value, ast.Name) and node.value.id in {"__builtins__", "builtins"}) + # obj.__builtins__['__spec__'] + or (isinstance(node.value, ast.Attribute) and node.value.attr in {"__builtins__", "builtins"}) + ) + + if is_builtins_access and isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + key = node.slice.value + if key in BLOCKED_ATTRIBUTES: + self._add_violation( + node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=key) + ) + + self.generic_visit(node) + # ========== Validation ========== def _validate_import(self, module_path: str, lineno: int) -> None: @@ -117,29 +121,12 @@ class SecurityValidator(ast.NodeVisitor): self.checked_modules.add(module_name) - is_stdlib = module_name in sys.stdlib_module_names - is_external = not is_stdlib - - if is_stdlib and (self._stdlib_allow_all or module_name in self.stdlib_allow): - return - - if is_external and ( - self._external_allow_all or module_name in self.external_allow - ): - return - - error, allowed_str = ( - (ERROR_STDLIB_DISALLOWED, self._stdlib_allowed_str) - if is_stdlib - else (ERROR_EXTERNAL_DISALLOWED, self._external_allowed_str) + is_allowed, error_msg = validate_module_import( + module_path, self.security_config ) - self._add_violation( - lineno, error.format(module=module_path, allowed=allowed_str) - ) - - def _format_allowed(self, allow_set: Set[str]) -> str: - return ", ".join(sorted(allow_set)) if allow_set else "none" + if not is_allowed: + self._add_violation(lineno, error_msg) def _add_violation(self, lineno: int, message: str) -> None: self.violations.append(f"Line {lineno}: {message}") @@ -148,14 +135,16 @@ class SecurityValidator(ast.NodeVisitor): class TaskAnalyzer: _cache: ValidationCache = OrderedDict() - def __init__(self, stdlib_allow: Set[str], external_allow: Set[str]): - self._stdlib_allow = stdlib_allow - self._external_allow = external_allow + def __init__(self, security_config: SecurityConfig): + self._security_config = security_config self._allowlists = ( - tuple(sorted(stdlib_allow)), - tuple(sorted(external_allow)), + tuple(sorted(security_config.stdlib_allow)), + tuple(sorted(security_config.external_allow)), + ) + self._allow_all = ( + "*" in security_config.stdlib_allow + and "*" in security_config.external_allow ) - self._allow_all = "*" in stdlib_allow and "*" in external_allow def validate(self, code: str) -> None: if self._allow_all: @@ -176,7 +165,7 @@ class TaskAnalyzer: tree = ast.parse(code) - security_validator = SecurityValidator(self._stdlib_allow, self._external_allow) + security_validator = SecurityValidator(self._security_config) security_validator.visit(tree) self._set_in_cache(cache_key, security_validator.violations) diff --git a/packages/@n8n/task-runner-python/src/task_executor.py b/packages/@n8n/task-runner-python/src/task_executor.py index b338af5379c..ff28b8dbb71 100644 --- a/packages/@n8n/task-runner-python/src/task_executor.py +++ b/packages/@n8n/task-runner-python/src/task_executor.py @@ -10,11 +10,15 @@ import logging from src.errors import ( TaskCancelledError, + TaskKilledError, TaskResultMissingError, TaskRuntimeError, TaskTimeoutError, - TaskProcessExitError, + TaskSubprocessFailedError, + SecurityViolationError, ) +from src.import_validation import validate_module_import +from src.config.security_config import SecurityConfig from src.message_types.broker import NodeMode, Items from src.constants import ( @@ -23,8 +27,9 @@ from src.constants import ( EXECUTOR_ALL_ITEMS_FILENAME, EXECUTOR_PER_ITEM_FILENAME, SIGTERM_EXIT_CODE, + SIGKILL_EXIT_CODE, ) -from typing import Any, Set +from typing import Any from multiprocessing.context import ForkServerProcess from multiprocessing import shared_memory @@ -45,9 +50,7 @@ class TaskExecutor: code: str, node_mode: NodeMode, items: Items, - stdlib_allow: Set[str], - external_allow: Set[str], - builtins_deny: set[str], + security_config: SecurityConfig, ): """Create a subprocess for executing a Python code task and a queue for communication.""" @@ -64,9 +67,7 @@ class TaskExecutor: code, items, queue, - stdlib_allow, - external_allow, - builtins_deny, + security_config, ), ) @@ -88,7 +89,7 @@ class TaskExecutor: process.start() except (ProcessLookupError, ConnectionError, BrokenPipeError) as e: logger.error(f"Failed to start child process: {e}") - raise TaskProcessExitError(-1) + raise TaskSubprocessFailedError(-1) process.join(timeout=task_timeout) @@ -99,9 +100,12 @@ class TaskExecutor: if process.exitcode == SIGTERM_EXIT_CODE: raise TaskCancelledError() + if process.exitcode == SIGKILL_EXIT_CODE: + raise TaskKilledError() + if process.exitcode != 0: assert process.exitcode is not None - raise TaskProcessExitError(process.exitcode) + raise TaskSubprocessFailedError(process.exitcode) try: returned = queue.get_nowait() @@ -165,15 +169,13 @@ class TaskExecutor: raw_code: str, items: Items, queue: multiprocessing.Queue, - stdlib_allow: Set[str], - external_allow: Set[str], - builtins_deny: set[str], + security_config: SecurityConfig, ): """Execute a Python code task in all-items mode.""" os.environ.clear() - TaskExecutor._sanitize_sys_modules(stdlib_allow, external_allow) + TaskExecutor._sanitize_sys_modules(security_config) print_args: PrintArgs = [] sys.stderr = stderr_capture = io.StringIO() @@ -183,7 +185,7 @@ class TaskExecutor: compiled_code = compile(wrapped_code, EXECUTOR_ALL_ITEMS_FILENAME, "exec") globals = { - "__builtins__": TaskExecutor._filter_builtins(builtins_deny), + "__builtins__": TaskExecutor._filter_builtins(security_config), "_items": items, "print": TaskExecutor._create_custom_print(print_args), } @@ -201,15 +203,13 @@ class TaskExecutor: raw_code: str, items: Items, queue: multiprocessing.Queue, - stdlib_allow: Set[str], - external_allow: Set[str], - builtins_deny: set[str], + security_config: SecurityConfig, ): """Execute a Python code task in per-item mode.""" os.environ.clear() - TaskExecutor._sanitize_sys_modules(stdlib_allow, external_allow) + TaskExecutor._sanitize_sys_modules(security_config) print_args: PrintArgs = [] sys.stderr = stderr_capture = io.StringIO() @@ -221,7 +221,7 @@ class TaskExecutor: result = [] for index, item in enumerate(items): globals = { - "__builtins__": TaskExecutor._filter_builtins(builtins_deny), + "__builtins__": TaskExecutor._filter_builtins(security_config), "_item": item, "print": TaskExecutor._create_custom_print(print_args), } @@ -233,8 +233,14 @@ class TaskExecutor: if user_output is None: continue - user_output["pairedItem"] = {"item": index} - result.append(user_output) + json_data = TaskExecutor._extract_json_data_per_item(user_output) + + item = {"json": json_data, "pairedItem": {"item": index}} + + if isinstance(user_output, dict) and "binary" in user_output: + item["binary"] = user_output["binary"] + + result.append(item) TaskExecutor._put_result(queue, result, print_args) @@ -245,6 +251,19 @@ class TaskExecutor: def _wrap_code(raw_code: str) -> str: indented_code = textwrap.indent(raw_code, " ") return f"def _user_function():\n{indented_code}\n\n{EXECUTOR_USER_OUTPUT_KEY} = _user_function()" + + @staticmethod + def _extract_json_data_per_item(user_output): + if not isinstance(user_output, dict): + return user_output + + if "json" in user_output: + return user_output["json"] + + if "binary" in user_output: + return {k: v for k, v in user_output.items() if k != "binary"} + + return user_output @staticmethod def _put_result( @@ -278,6 +297,7 @@ class TaskExecutor: "message": f"Process exited with code {e.code}" if isinstance(e, SystemExit) else str(e), + "description": getattr(e, "description", ""), "stack": traceback.format_exc(), "stderr": stderr, } @@ -363,16 +383,24 @@ class TaskExecutor: # ========== security ========== @staticmethod - def _filter_builtins(builtins_deny: set[str]): + def _filter_builtins(security_config: SecurityConfig): """Get __builtins__ with denied ones removed.""" - if len(builtins_deny) == 0: - return __builtins__ + if len(security_config.builtins_deny) == 0: + filtered = dict(__builtins__) + else: + filtered = { + k: v + for k, v in __builtins__.items() + if k not in security_config.builtins_deny + } - return {k: v for k, v in __builtins__.items() if k not in builtins_deny} + filtered["__import__"] = TaskExecutor._create_safe_import(security_config) + + return filtered @staticmethod - def _sanitize_sys_modules(stdlib_allow: Set[str], external_allow: Set[str]): + def _sanitize_sys_modules(security_config: SecurityConfig): safe_modules = { "builtins", "__main__", @@ -383,19 +411,19 @@ class TaskExecutor: "importlib.machinery", } - if "*" in stdlib_allow: + if "*" in security_config.stdlib_allow: safe_modules.update(sys.stdlib_module_names) else: - safe_modules.update(stdlib_allow) + safe_modules.update(security_config.stdlib_allow) - if "*" in external_allow: + if "*" in security_config.external_allow: safe_modules.update( name for name in sys.modules.keys() if name not in sys.stdlib_module_names ) else: - safe_modules.update(external_allow) + safe_modules.update(security_config.external_allow) # keep modules marked as safe and submodules of those modules_to_remove = [ @@ -407,3 +435,21 @@ class TaskExecutor: for module_name in modules_to_remove: del sys.modules[module_name] + + @staticmethod + def _create_safe_import(security_config: SecurityConfig): + original_import = __builtins__["__import__"] + + def safe_import(name, *args, **kwargs): + is_allowed, error_msg = validate_module_import(name, security_config) + + if not is_allowed: + assert error_msg is not None + raise SecurityViolationError( + message="Security violation detected", + description=error_msg, + ) + + return original_import(name, *args, **kwargs) + + return safe_import diff --git a/packages/@n8n/task-runner-python/src/task_runner.py b/packages/@n8n/task-runner-python/src/task_runner.py index 0dc920bc86b..08cdb099e33 100644 --- a/packages/@n8n/task-runner-python/src/task_runner.py +++ b/packages/@n8n/task-runner-python/src/task_runner.py @@ -54,6 +54,7 @@ from src.message_serde import MessageSerde from src.task_state import TaskState, TaskStatus from src.task_executor import TaskExecutor from src.task_analyzer import TaskAnalyzer +from src.config.security_config import SecurityConfig class TaskOffer: @@ -84,7 +85,12 @@ class TaskRunner: self.offers_coroutine: Optional[asyncio.Task] = None self.serde = MessageSerde() self.executor = TaskExecutor() - self.analyzer = TaskAnalyzer(config.stdlib_allow, config.external_allow) + self.security_config = SecurityConfig( + stdlib_allow=config.stdlib_allow, + external_allow=config.external_allow, + builtins_deny=config.builtins_deny, + ) + self.analyzer = TaskAnalyzer(self.security_config) self.logger = logging.getLogger(__name__) self.idle_coroutine: Optional[asyncio.Task] = None @@ -297,9 +303,7 @@ class TaskRunner: code=task_settings.code, node_mode=task_settings.node_mode, items=task_settings.items, - stdlib_allow=self.config.stdlib_allow, - external_allow=self.config.external_allow, - builtins_deny=self.config.builtins_deny, + security_config=self.security_config, ) task_state.process = process diff --git a/packages/@n8n/task-runner-python/tests/integration/test_execution.py b/packages/@n8n/task-runner-python/tests/integration/test_execution.py index 03d6ff10af0..f88c7a0e547 100644 --- a/packages/@n8n/task-runner-python/tests/integration/test_execution.py +++ b/packages/@n8n/task-runner-python/tests/integration/test_execution.py @@ -94,9 +94,47 @@ async def test_per_item_with_success(broker, manager): assert done_msg["taskId"] == task_id assert done_msg["data"]["result"] == [ - {"doubled": 20, "pairedItem": {"item": 0}}, - {"doubled": 40, "pairedItem": {"item": 1}}, - {"doubled": 60, "pairedItem": {"item": 2}}, + {"json": {"doubled": 20}, "pairedItem": {"item": 0}}, + {"json": {"doubled": 40}, "pairedItem": {"item": 1}}, + {"json": {"doubled": 60}, "pairedItem": {"item": 2}}, + ] + + +@pytest.mark.asyncio +async def test_per_item_with_explicit_json_and_binary(broker, manager): + task_id = nanoid() + items = [{"json": {"value": 10}}] + code = "return {'json': {'custom': 'data'}, 'binary': {'file': 'data'}}" + task_settings = create_task_settings(code=code, node_mode="per_item", items=items) + await broker.send_task(task_id=task_id, task_settings=task_settings) + + result = await wait_for_task_done(broker, task_id) + + assert result["data"]["result"] == [ + { + "json": {"custom": "data"}, + "binary": {"file": "data"}, + "pairedItem": {"item": 0} + } + ] + + +@pytest.mark.asyncio +async def test_per_item_with_binary_only(broker, manager): + task_id = nanoid() + items = [{"json": {"value": 10}}] + code = "return {'binary': {'file': 'data'}}" + task_settings = create_task_settings(code=code, node_mode="per_item", items=items) + await broker.send_task(task_id=task_id, task_settings=task_settings) + + result = await wait_for_task_done(broker, task_id) + + assert result["data"]["result"] == [ + { + "json": {}, + "binary": {"file": "data"}, + "pairedItem": {"item": 0} + } ] @@ -122,8 +160,8 @@ async def test_per_item_with_filtering(broker, manager): result = await wait_for_task_done(broker, task_id) assert result["data"]["result"] == [ - {"value": 15, "passed": True, "pairedItem": {"item": 1}}, - {"value": 25, "passed": True, "pairedItem": {"item": 2}}, + {"json": {"value": 15, "passed": True}, "pairedItem": {"item": 1}}, + {"json": {"value": 25, "passed": True}, "pairedItem": {"item": 2}}, ] @@ -246,3 +284,53 @@ async def test_stdlib_submodules_with_wildcard(broker, manager_with_stdlib_wildc result = await wait_for_task_done(broker, task_id) assert result["data"]["result"] == [{"json": {"is_iterable": True}}] + + +@pytest.mark.asyncio +async def test_cannot_bypass_import_restrictions_via_builtins_dict(broker, manager): + task_id = nanoid() + code = textwrap.dedent(""" + os = __builtins__['__import__']('os') + print(os.getpid()) + return [] + """) + task_settings = create_task_settings(code=code, node_mode="all_items") + await broker.send_task(task_id=task_id, task_settings=task_settings) + + error_msg = await wait_for_task_error(broker, task_id) + + assert error_msg["taskId"] == task_id + assert "error" in error_msg + assert "__import__" in str(error_msg["error"]["description"]).lower() + + +@pytest.mark.asyncio +async def test_cannot_bypass_import_restrictions_via_builtins_spec_loader(broker, manager): + task_id = nanoid() + code = textwrap.dedent(""" + sys = __builtins__['__spec__'].loader.load_module('sys') + os = sys.meta_path[-1].find_spec("os").loader.load_module('os') + return [{"json": {"pid": os.getpid()}}] + """) + task_settings = create_task_settings(code=code, node_mode="all_items") + await broker.send_task(task_id=task_id, task_settings=task_settings) + + error_msg = await wait_for_task_error(broker, task_id) + + assert error_msg["taskId"] == task_id + assert "error" in error_msg + +@pytest.mark.asyncio +async def test_cannot_bypass_import_restrictions_via_sys_builtins_spec_leader(broker, manager_with_stdlib_wildcard): + task_id = nanoid() + code = textwrap.dedent(""" + import sys + os = sys.__builtins__['__spec__'].loader.load_module('os') + return [{"json": {"pid": os.getpid()}}] + """) + task_settings = create_task_settings(code=code, node_mode="all_items") + await broker.send_task(task_id=task_id, task_settings=task_settings) + error_msg = await wait_for_task_error(broker, task_id) + + assert error_msg["taskId"] == task_id + assert "error" in error_msg diff --git a/packages/@n8n/task-runner-python/tests/unit/test_task_analyzer.py b/packages/@n8n/task-runner-python/tests/unit/test_task_analyzer.py index ea23f96926e..f4b72abcd2b 100644 --- a/packages/@n8n/task-runner-python/tests/unit/test_task_analyzer.py +++ b/packages/@n8n/task-runner-python/tests/unit/test_task_analyzer.py @@ -2,12 +2,14 @@ import pytest from src.errors.security_violation_error import SecurityViolationError from src.task_analyzer import TaskAnalyzer +from src.config.security_config import SecurityConfig +from src.constants import BLOCKED_ATTRIBUTES class TestTaskAnalyzer: @pytest.fixture def analyzer(self) -> TaskAnalyzer: - return TaskAnalyzer( + security_config = SecurityConfig( stdlib_allow={ "json", "math", @@ -21,8 +23,11 @@ class TestTaskAnalyzer: "operator", }, external_allow=set(), + builtins_deny=set(), ) + return TaskAnalyzer(security_config) + class TestImportValidation(TestTaskAnalyzer): def test_allowed_standard_imports(self, analyzer: TaskAnalyzer) -> None: @@ -65,53 +70,12 @@ class TestImportValidation(TestTaskAnalyzer): class TestAttributeAccessValidation(TestTaskAnalyzer): - def test_always_blocked_attributes(self, analyzer: TaskAnalyzer) -> None: - blocked_attributes = [ - "obj.__subclasses__", - "obj.__globals__", - "obj.__builtins__", - "obj.__traceback__", - "obj.tb_frame", - ] - - for code in blocked_attributes: - with pytest.raises(SecurityViolationError): + def test_all_blocked_attributes_are_blocked(self, analyzer: TaskAnalyzer) -> None: + for attr in BLOCKED_ATTRIBUTES: + code = f"obj.{attr}" + with pytest.raises(SecurityViolationError) as exc_info: analyzer.validate(code) - - def test_conditionally_blocked_in_chains(self, analyzer: TaskAnalyzer) -> None: - blocked_chains = [ - "x.__class__.__bases__", - "obj.__class__.__mro__", - "something.__init__.__globals__", - "obj.__class__.__code__", - "func.__func__.__closure__", - ] - - for code in blocked_chains: - with pytest.raises(SecurityViolationError): - analyzer.validate(code) - - def test_conditionally_blocked_on_literals(self, analyzer: TaskAnalyzer) -> None: - blocked_literals = [ - '"".__class__', - '"test".__class__', - "(0).__class__", - "(42).__class__", - "(3.14).__class__", - ] - - for code in blocked_literals: - with pytest.raises(SecurityViolationError): - analyzer.validate(code) - - allowed_literals = [ - "[].__class__", - "{}.__class__", - "().__class__", - ] - - for code in allowed_literals: - analyzer.validate(code) + assert attr in exc_info.value.description.lower() def test_allowed_attribute_access(self, analyzer: TaskAnalyzer) -> None: allowed_attributes = [ @@ -126,17 +90,6 @@ class TestAttributeAccessValidation(TestTaskAnalyzer): for code in allowed_attributes: analyzer.validate(code) - def test_safe_class_usage(self, analyzer: TaskAnalyzer) -> None: - safe_code = """ -class MyClass: - def __init__(self): - self.value = 42 - -obj = MyClass() -result = obj.__class__.__name__ -""" - analyzer.validate(safe_code) - class TestDynamicImportDetection(TestTaskAnalyzer): def test_various_dynamic_import_patterns(self, analyzer: TaskAnalyzer) -> None: @@ -164,7 +117,10 @@ class TestDynamicImportDetection(TestTaskAnalyzer): class TestAllowAll(TestTaskAnalyzer): def test_allow_all_bypasses_validation(self) -> None: - analyzer = TaskAnalyzer(stdlib_allow={"*"}, external_allow={"*"}) + security_config = SecurityConfig( + stdlib_allow={"*"}, external_allow={"*"}, builtins_deny=set() + ) + analyzer = TaskAnalyzer(security_config) unsafe_allowed_code = [ "import os", diff --git a/packages/@n8n/task-runner-python/tests/unit/test_task_executor.py b/packages/@n8n/task-runner-python/tests/unit/test_task_executor.py new file mode 100644 index 00000000000..792ca1aa7a7 --- /dev/null +++ b/packages/@n8n/task-runner-python/tests/unit/test_task_executor.py @@ -0,0 +1,74 @@ +import pytest +from unittest.mock import MagicMock +from queue import Empty + +from src.task_executor import TaskExecutor +from src.errors import TaskCancelledError, TaskKilledError, TaskSubprocessFailedError +from src.constants import SIGTERM_EXIT_CODE, SIGKILL_EXIT_CODE + + +class TestTaskExecutorProcessExitHandling: + def test_sigterm_raises_task_cancelled_error(self): + process = MagicMock() + process.is_alive.return_value = False + process.exitcode = SIGTERM_EXIT_CODE + + queue = MagicMock() + + with pytest.raises(TaskCancelledError): + TaskExecutor.execute_process( + process=process, + queue=queue, + task_timeout=60, + continue_on_fail=False, + ) + + def test_sigkill_raises_task_killed_error(self): + process = MagicMock() + process.is_alive.return_value = False + process.exitcode = SIGKILL_EXIT_CODE + + queue = MagicMock() + + with pytest.raises(TaskKilledError): + TaskExecutor.execute_process( + process=process, + queue=queue, + task_timeout=60, + continue_on_fail=False, + ) + + def test_other_non_zero_exit_code_raises_task_subprocess_failed_error(self): + process = MagicMock() + process.is_alive.return_value = False + process.exitcode = -1 # Some other error code + + queue = MagicMock() + + with pytest.raises(TaskSubprocessFailedError) as exc_info: + TaskExecutor.execute_process( + process=process, + queue=queue, + task_timeout=60, + continue_on_fail=False, + ) + + assert exc_info.value.exit_code == -1 + + def test_zero_exit_code_with_empty_queue_raises_task_result_missing_error(self): + from src.errors import TaskResultMissingError + + process = MagicMock() + process.is_alive.return_value = False + process.exitcode = 0 + + queue = MagicMock() + queue.get_nowait.side_effect = Empty() + + with pytest.raises(TaskResultMissingError): + TaskExecutor.execute_process( + process=process, + queue=queue, + task_timeout=60, + continue_on_fail=False, + ) diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 60a0a2bd6b1..d169484458c 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.51.0", + "version": "1.52.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index 73247bf8283..d82449598c2 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -18,8 +18,8 @@ } }, "scripts": { - "dev": "vite", - "build": "tsup", + "dev": "tsdown --watch", + "build": "tsdown", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -35,7 +35,7 @@ "@n8n/vitest-config": "workspace:*", "@testing-library/jest-dom": "catalog:frontend", "@testing-library/user-event": "catalog:frontend", - "tsup": "catalog:", + "tsdown": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/@n8n/utils/tsconfig.json b/packages/@n8n/utils/tsconfig.json index 10f9ee1b738..61cbaa9b324 100644 --- a/packages/@n8n/utils/tsconfig.json +++ b/packages/@n8n/utils/tsconfig.json @@ -7,5 +7,5 @@ "types": ["vite/client", "vitest/globals"], "isolatedModules": true }, - "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsup.config.ts"] + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsdown.config.ts"] } diff --git a/packages/@n8n/utils/tsup.config.ts b/packages/@n8n/utils/tsdown.config.ts similarity index 71% rename from packages/@n8n/utils/tsup.config.ts rename to packages/@n8n/utils/tsdown.config.ts index 0d555b1ab07..d1641d4c15e 100644 --- a/packages/@n8n/utils/tsup.config.ts +++ b/packages/@n8n/utils/tsdown.config.ts @@ -1,11 +1,9 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__**/*'], format: ['cjs', 'esm'], clean: true, dts: true, - cjsInterop: true, - splitting: true, sourcemap: true, }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e98d407bac..d617ac388a5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.115.0", + "version": "1.116.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index b41e64c094c..c2366159028 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -116,7 +116,6 @@ export class ImportCredentialsCommand extends BaseCommand, project: Project) { - // @ts-ignore CAT-957 const result = await this.transactionManager.upsert(CredentialsEntity, credential, ['id']); const sharingExists = await this.transactionManager.existsBy(SharedCredentials, { diff --git a/packages/cli/src/controllers/__tests__/ai.controller.test.ts b/packages/cli/src/controllers/__tests__/ai.controller.test.ts index 9f8f8ae1f63..552d262a19f 100644 --- a/packages/cli/src/controllers/__tests__/ai.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/ai.controller.test.ts @@ -3,6 +3,8 @@ import type { AiApplySuggestionRequestDto, AiChatRequestDto, AiBuilderChatRequestDto, + AiSessionRetrievalRequestDto, + AiSessionMetadataResponseDto, } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; @@ -423,4 +425,55 @@ describe('AiController', () => { expect(workflowBuilderService.getBuilderInstanceCredits).toHaveBeenCalledWith(request.user); }); }); + + describe('getSessionsMetadata', () => { + const payload: AiSessionRetrievalRequestDto = { + workflowId: 'workflow123', + }; + + it('should return sessions metadata successfully when messages exist', async () => { + const expectedMetadata: AiSessionMetadataResponseDto = { + hasMessages: true, + }; + + workflowBuilderService.getSessionsMetadata.mockResolvedValue(expectedMetadata); + + const result = await controller.getSessionsMetadata(request, response, payload); + + expect(workflowBuilderService.getSessionsMetadata).toHaveBeenCalledWith( + payload.workflowId, + request.user, + ); + expect(result).toEqual(expectedMetadata); + }); + + it('should return sessions metadata successfully when no messages exist', async () => { + const expectedMetadata: AiSessionMetadataResponseDto = { + hasMessages: false, + }; + + workflowBuilderService.getSessionsMetadata.mockResolvedValue(expectedMetadata); + + const result = await controller.getSessionsMetadata(request, response, payload); + + expect(workflowBuilderService.getSessionsMetadata).toHaveBeenCalledWith( + payload.workflowId, + request.user, + ); + expect(result).toEqual(expectedMetadata); + }); + + it('should throw InternalServerError if getting sessions metadata fails', async () => { + const mockError = new Error('Failed to get sessions metadata'); + workflowBuilderService.getSessionsMetadata.mockRejectedValue(mockError); + + await expect(controller.getSessionsMetadata(request, response, payload)).rejects.toThrow( + InternalServerError, + ); + expect(workflowBuilderService.getSessionsMetadata).toHaveBeenCalledWith( + payload.workflowId, + request.user, + ); + }); + }); }); diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts index b17537baf89..ffb88ce4566 100644 --- a/packages/cli/src/controllers/__tests__/users.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -10,6 +10,7 @@ describe('UsersController', () => { const eventService = mock(); const userRepository = mock(); const projectService = mock(); + const controller = new UsersController( mock(), mock(), diff --git a/packages/cli/src/controllers/ai.controller.ts b/packages/cli/src/controllers/ai.controller.ts index 393828f519b..bff4e8db151 100644 --- a/packages/cli/src/controllers/ai.controller.ts +++ b/packages/cli/src/controllers/ai.controller.ts @@ -7,6 +7,7 @@ import { AiFreeCreditsRequestDto, AiBuilderChatRequestDto, AiSessionRetrievalRequestDto, + AiSessionMetadataResponseDto, } from '@n8n/api-types'; import { AuthenticatedRequest } from '@n8n/db'; import { Body, Get, Licensed, Post, RestController } from '@n8n/decorators'; @@ -225,6 +226,25 @@ export class AiController { } } + @Licensed('feat:aiBuilder') + @Post('/sessions/metadata', { rateLimit: { limit: 100 } }) + async getSessionsMetadata( + req: AuthenticatedRequest, + _: Response, + @Body payload: AiSessionRetrievalRequestDto, + ): Promise { + try { + const metadata = await this.workflowBuilderService.getSessionsMetadata( + payload.workflowId, + req.user, + ); + return metadata; + } catch (e) { + assert(e instanceof Error); + throw new InternalServerError(e.message, e); + } + } + @Licensed('feat:aiBuilder') @Get('/build/credits') async getBuilderCredits( diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index b9917dcb05d..feab0f89b72 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -31,6 +31,7 @@ import { Param, Query, } from '@n8n/decorators'; +import { hasGlobalScope } from '@n8n/permissions'; import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; @@ -45,7 +46,6 @@ import { FolderService } from '@/services/folder.service'; import { ProjectService } from '@/services/project.service.ee'; import { UserService } from '@/services/user.service'; import { WorkflowService } from '@/workflows/workflow.service'; -import { hasGlobalScope } from '@n8n/permissions'; @RestController('/users') export class UsersController { diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 59ca4fd468d..a3c4f360d79 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -472,7 +472,6 @@ export class CredentialsHelper extends ICredentialsHelper { type, }; - // @ts-ignore CAT-957 await this.credentialsRepository.update(findQuery, newCredentialsData); } @@ -498,7 +497,6 @@ export class CredentialsHelper extends ICredentialsHelper { type, }; - // @ts-ignore CAT-957 await this.credentialsRepository.update(findQuery, newCredentialsData); } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 49ebda1088a..d9be637e0cc 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -387,7 +387,6 @@ export class CredentialsService { await this.externalHooks.run('credentials.update', [newCredentialData]); // Update the credentials in DB - // @ts-ignore CAT-957 await this.credentialsRepository.update(credentialId, newCredentialData); // We sadly get nothing back from "update". Neither if it updated a record diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 7c4c4baaa56..20af85429de 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -793,7 +793,6 @@ export class SourceControlImportService { newSharedCredential.projectId = remoteOwnerProject?.id ?? personalProject.id; newSharedCredential.role = 'credential:owner'; - // @ts-ignore CAT-957 await this.sharedCredentialsRepository.upsert({ ...newSharedCredential }, [ 'credentialsId', 'projectId', @@ -849,7 +848,6 @@ export class SourceControlImportService { } const tagCopy = this.tagRepository.create(tag); - // @ts-ignore CAT-957 await this.tagRepository.upsert(tagCopy, { skipUpdateIfNoValuesChanged: true, conflictPaths: { id: true }, @@ -905,7 +903,6 @@ export class SourceControlImportService { }, }); - // @ts-ignore CAT-957 await this.folderRepository.upsert(folderCopy, { skipUpdateIfNoValuesChanged: true, conflictPaths: { id: true }, @@ -964,13 +961,11 @@ export class SourceControlImportService { overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1); } try { - // @ts-ignore Workaround for intermittent typecheck issue with _QueryDeepPartialEntity await this.variablesRepository.upsert({ ...variable }, ['id']); } catch (errorUpsert) { if (isUniqueConstraintError(errorUpsert as Error)) { this.logger.debug(`Variable ${variable.key} already exists, updating instead`); try { - // @ts-ignore Workaround for intermittent typecheck issue with _QueryDeepPartialEntity await this.variablesRepository.update({ key: variable.key }, { ...variable }); } catch (errorUpdate) { this.logger.debug(`Failed to update variable ${variable.key}, skipping`); diff --git a/packages/cli/src/environments.ee/variables/variables.service.ee.ts b/packages/cli/src/environments.ee/variables/variables.service.ee.ts index 051a73455ca..2d261b7dd9d 100644 --- a/packages/cli/src/environments.ee/variables/variables.service.ee.ts +++ b/packages/cli/src/environments.ee/variables/variables.service.ee.ts @@ -205,7 +205,6 @@ export class VariablesService { // Check that variable key is unique (globally or in the project) await this.validateUniqueVariable(variable.key, variable.projectId); - this.eventService.emit('variable-created'); const saveResult = await this.variablesRepository.save( { ...variable, @@ -214,6 +213,9 @@ export class VariablesService { }, { transaction: false }, ); + this.eventService.emit('variable-created', { + projectId: variable.projectId, + }); await this.updateCache(); return saveResult; } @@ -268,6 +270,9 @@ export class VariablesService { ? { project: variable.projectId ? { id: variable.projectId } : null } : {}), }); + this.eventService.emit('variable-updated', { + projectId: newProjectId, + }); await this.updateCache(); return (await this.getCached(id))!; } diff --git a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts index a624673cd08..565fafe7afb 100644 --- a/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts +++ b/packages/cli/src/eventbus/message-event-bus-destination/message-event-bus-destination-syslog.ee.ts @@ -57,6 +57,7 @@ export class MessageEventBusDestinationSyslog facility: syslog.Facility.Local0, // severity: syslog.Severity.Error, port: this.port, + rfc3164: false, transport: options.protocol !== undefined && options.protocol === 'tcp' ? syslog.Transport.Tcp diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 7d0b0fd0ec7..bce4deb370a 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -333,7 +333,25 @@ describe('TelemetryEventRelay', () => { it('should track on `variable-created` event', () => { eventService.emit('variable-created', {}); - expect(telemetry.track).toHaveBeenCalledWith('User created variable'); + expect(telemetry.track).toHaveBeenCalledWith('User created variable', {}); + + eventService.emit('variable-created', { projectId: 'projectId' }); + + expect(telemetry.track).toHaveBeenCalledWith('User created variable', { + project_id: 'projectId', + }); + }); + + it('should track on `variable-updated` event', () => { + eventService.emit('variable-updated', {}); + + expect(telemetry.track).toHaveBeenCalledWith('User updated variable', {}); + + eventService.emit('variable-updated', { projectId: 'projectId' }); + + expect(telemetry.track).toHaveBeenCalledWith('User updated variable', { + project_id: 'projectId', + }); }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 7c61058c67e..6de204395f6 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -88,6 +88,20 @@ export type RelayEventMap = { publicApi: boolean; }; + 'workflow-activated': { + user: UserLike; + workflowId: string; + workflow: IWorkflowDb; + publicApi: boolean; + }; + + 'workflow-deactivated': { + user: UserLike; + workflowId: string; + workflow: IWorkflowDb; + publicApi: boolean; + }; + 'workflow-pre-execute': { executionId: string; data: IWorkflowExecutionDataProcess /* main process */ | IWorkflowBase /* worker */; @@ -463,7 +477,13 @@ export type RelayEventMap = { // #region Variable - 'variable-created': {}; + 'variable-created': { + projectId?: string; + }; + + 'variable-updated': { + projectId?: string; + }; // #endregion diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 891e23d5be5..eab8eed5df4 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -63,7 +63,8 @@ export class TelemetryEventRelay extends EventRelay { this.sourceControlUserFinishedPushUi(event), 'license-renewal-attempted': (event) => this.licenseRenewalAttempted(event), 'license-community-plus-registered': (event) => this.licenseCommunityPlusRegistered(event), - 'variable-created': () => this.variableCreated(), + 'variable-created': (event) => this.variableCreated(event), + 'variable-updated': (event) => this.variableUpdated(event), 'external-secrets-provider-settings-saved': (event) => this.externalSecretsProviderSettingsSaved(event), 'public-api-invoked': (event) => this.publicApiInvoked(event), @@ -272,8 +273,16 @@ export class TelemetryEventRelay extends EventRelay { // #region Variable - private variableCreated() { - this.telemetry.track('User created variable'); + private variableCreated(event: RelayEventMap['variable-created']) { + this.telemetry.track('User created variable', { + project_id: event.projectId, + }); + } + + private variableUpdated(event: RelayEventMap['variable-updated']) { + this.telemetry.track('User updated variable', { + project_id: event.projectId, + }); } // #endregion diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts index 5b842872243..237a59f171e 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts @@ -13,6 +13,7 @@ export class WorkflowSelect extends BaseSelect { 'ownedBy', // non-entity field 'parentFolder', 'nodes', + 'isArchived', ]); } diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts new file mode 100644 index 00000000000..bf2b9e8cb29 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts @@ -0,0 +1,636 @@ +import { testDb, testModules } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { createAdmin, createMember } from '@test-integration/db/users'; + +import { ChatHubService } from '../chat-hub.service'; +import { ChatHubMessageRepository } from '../chat-message.repository'; +import { ChatHubSessionRepository } from '../chat-session.repository'; + +beforeAll(async () => { + await testModules.loadModules(['chat-hub']); + await testDb.init(); +}); + +beforeEach(async () => { + await testDb.truncate(['ChatHubMessage', 'ChatHubSession']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('chatHub', () => { + let chatHubService: ChatHubService; + let messagesRepository: ChatHubMessageRepository; + let sessionsRepository: ChatHubSessionRepository; + + let admin: User; + let member: User; + + beforeAll(() => { + chatHubService = Container.get(ChatHubService); + messagesRepository = Container.get(ChatHubMessageRepository); + sessionsRepository = Container.get(ChatHubSessionRepository); + }); + + beforeEach(async () => { + admin = await createAdmin(); + member = await createMember(); + }); + + afterEach(async () => { + await chatHubService.deleteAllSessions(); + }); + + describe('getConversations', () => { + it('should list empty conversations', async () => { + const conversations = await chatHubService.getConversations(member.id); + expect(conversations).toBeDefined(); + expect(conversations).toHaveLength(0); + }); + + it("should list user's own conversations in expected order", async () => { + const session1 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + }); + const session2 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 2', + lastMessageAt: new Date('2025-01-02T00:00:00Z'), + }); + const session3 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 3', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + }); + await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: admin.id, + title: 'admin session', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + }); + + const conversations = await chatHubService.getConversations(member.id); + expect(conversations).toHaveLength(3); + expect(conversations[0].id).toBe(session1.id); + expect(conversations[1].id).toBe(session2.id); + expect(conversations[2].id).toBe(session3.id); + }); + }); + + describe('getConversation', () => { + it('should fail to get non-existing conversation', async () => { + await expect( + chatHubService.getConversation(member.id, '00000000-4040-4040-4040-000000000000'), + ).rejects.toThrow('Chat session not found'); + }); + + it("should fail to get another user's conversation", async () => { + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: admin.id, + title: 'admin session', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + }); + await expect(chatHubService.getConversation(member.id, session.id)).rejects.toThrow( + 'Chat session not found', + ); + }); + + it('should get conversation with no messages', async () => { + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + }); + const conversation = await chatHubService.getConversation(member.id, session.id); + expect(conversation).toBeDefined(); + expect(conversation.session.id).toBe(session.id); + expect(conversation.conversation.messages).toEqual({}); + }); + + it('should get conversation with messages in expected order', async () => { + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + }); + const ids = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; + + const msg1 = await messagesRepository.createChatMessage({ + id: ids[0], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 1', + turnId: ids[0], + createdAt: new Date('2025-01-03T00:00:00Z'), + }); + const msg2 = await messagesRepository.createChatMessage({ + id: ids[1], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 2', + previousMessageId: msg1.id, + turnId: ids[0], + createdAt: new Date('2025-01-03T00:05:00Z'), + }); + const msg3 = await messagesRepository.createChatMessage({ + id: ids[2], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 3', + previousMessageId: msg2.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:10:00Z'), + }); + const msg4 = await messagesRepository.createChatMessage({ + id: ids[3], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4', + previousMessageId: msg3.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:15:00Z'), + }); + + const response = await chatHubService.getConversation(member.id, session.id); + expect(response.session.id).toBe(session.id); + expect(response).toBeDefined(); + + const { + conversation: { rootIds, messages, activeMessageChain }, + } = response; + + expect(rootIds).toEqual([msg1.id]); + expect(Object.keys(messages)).toHaveLength(4); + expect(activeMessageChain).toHaveLength(4); + expect(activeMessageChain[0]).toBe(msg1.id); + expect(activeMessageChain[1]).toBe(msg2.id); + expect(activeMessageChain[2]).toBe(msg3.id); + expect(activeMessageChain[3]).toBe(msg4.id); + expect(messages[msg1.id].content).toBe('message 1'); + expect(messages[msg1.id].type).toBe('human'); + expect(messages[msg1.id].turnId).toBe(msg1.id); + expect(messages[msg2.id].content).toBe('message 2'); + expect(messages[msg2.id].type).toBe('ai'); + expect(messages[msg2.id].turnId).toBe(msg1.id); + expect(messages[msg3.id].content).toBe('message 3'); + expect(messages[msg3.id].type).toBe('human'); + expect(messages[msg3.id].turnId).toBe(msg3.id); + expect(messages[msg4.id].content).toBe('message 4'); + expect(messages[msg4.id].type).toBe('ai'); + expect(messages[msg4.id].turnId).toBe(msg3.id); + }); + + it('should get conversation with a edit branch', async () => { + const ids = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; + + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + }); + const msg1 = await messagesRepository.createChatMessage({ + id: ids[0], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 1', + turnId: ids[0], + createdAt: new Date('2025-01-03T00:00:00Z'), + }); + const msg2 = await messagesRepository.createChatMessage({ + id: ids[1], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 2', + previousMessageId: msg1.id, + turnId: ids[0], + createdAt: new Date('2025-01-03T00:05:00Z'), + }); + const msg3 = await messagesRepository.createChatMessage({ + id: ids[2], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 3a', + previousMessageId: msg2.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:10:00Z'), + }); + const msg4 = await messagesRepository.createChatMessage({ + id: ids[3], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4a', + previousMessageId: msg3.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:15:00Z'), + }); + // Edit message 3 to create a branch + const msg5 = await messagesRepository.createChatMessage({ + id: ids[4], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 3b', + previousMessageId: msg2.id, + revisionOfMessageId: msg3.id, + turnId: ids[4], + createdAt: new Date('2025-01-03T00:20:00Z'), + }); + const msg6 = await messagesRepository.createChatMessage({ + id: ids[5], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4b', + previousMessageId: msg5.id, + turnId: ids[4], + createdAt: new Date('2025-01-03T00:25:00Z'), + }); + + const response = await chatHubService.getConversation(member.id, session.id); + expect(response.session.id).toBe(session.id); + expect(response).toBeDefined(); + + const { + conversation: { rootIds, messages, activeMessageChain }, + } = response; + + expect(rootIds).toEqual([msg1.id]); + expect(Object.keys(messages)).toHaveLength(6); + expect(activeMessageChain).toHaveLength(4); + expect(activeMessageChain[0]).toBe(msg1.id); + expect(activeMessageChain[1]).toBe(msg2.id); + expect(activeMessageChain[2]).toBe(msg5.id); + expect(activeMessageChain[3]).toBe(msg6.id); + expect(messages[msg1.id].content).toBe('message 1'); + expect(messages[msg2.id].content).toBe('message 2'); + expect(messages[msg3.id].content).toBe('message 3a'); + expect(messages[msg4.id].content).toBe('message 4a'); + expect(messages[msg5.id].content).toBe('message 3b'); + expect(messages[msg6.id].content).toBe('message 4b'); + expect(messages[msg3.id].revisionIds).toEqual([msg5.id]); + expect(messages[msg3.id].responseIds).toEqual([msg4.id]); + expect(messages[msg3.id].retryIds).toEqual([]); + expect(messages[msg5.id].previousMessageId).toBe(msg2.id); + }); + + it('should get conversation with a edit branch at first message', async () => { + const ids = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + }); + + const msg1 = await messagesRepository.createChatMessage({ + id: ids[0], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 1a', + turnId: ids[0], + createdAt: new Date('2025-01-03T00:00:00Z'), + }); + await messagesRepository.createChatMessage({ + id: ids[1], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 2a', + previousMessageId: msg1.id, + turnId: ids[1], + createdAt: new Date('2025-01-03T00:05:00Z'), + }); + // Edit message 1 to create a branch + const msg3 = await messagesRepository.createChatMessage({ + id: ids[2], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 1b', + revisionOfMessageId: msg1.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:10:00Z'), + }); + const msg4 = await messagesRepository.createChatMessage({ + id: ids[3], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 2b', + previousMessageId: msg3.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:15:00Z'), + }); + + const response = await chatHubService.getConversation(member.id, session.id); + expect(response.session.id).toBe(session.id); + expect(response).toBeDefined(); + + const { + conversation: { rootIds, messages, activeMessageChain }, + } = response; + + expect(rootIds).toEqual([msg1.id, msg3.id]); + expect(Object.keys(messages)).toHaveLength(4); + expect(activeMessageChain).toHaveLength(2); + expect(activeMessageChain[0]).toBe(msg3.id); + expect(activeMessageChain[1]).toBe(msg4.id); + }); + + it('should get conversation with a retry branch at last message', async () => { + const ids = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; + + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + }); + const msg1 = await messagesRepository.createChatMessage({ + id: ids[0], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 1', + turnId: ids[0], + createdAt: new Date('2025-01-03T00:00:00Z'), + }); + const msg2 = await messagesRepository.createChatMessage({ + id: ids[1], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 2', + previousMessageId: msg1.id, + turnId: ids[0], + createdAt: new Date('2025-01-03T00:05:00Z'), + }); + const msg3 = await messagesRepository.createChatMessage({ + id: ids[2], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 3', + previousMessageId: msg2.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:10:00Z'), + }); + const msg4 = await messagesRepository.createChatMessage({ + id: ids[3], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4a', + previousMessageId: msg3.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:15:00Z'), + }); + // Retry message 4 to create a branch + const msg5 = await messagesRepository.createChatMessage({ + id: ids[4], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4b', + previousMessageId: msg3.id, + retryOfMessageId: msg4.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:20:00Z'), + }); + + const response = await chatHubService.getConversation(member.id, session.id); + expect(response).toBeDefined(); + expect(response.session.id).toBe(session.id); + + const { + conversation: { rootIds, messages, activeMessageChain }, + } = response; + + expect(rootIds).toEqual([msg1.id]); + expect(Object.keys(messages)).toHaveLength(5); + expect(activeMessageChain).toHaveLength(4); + expect(activeMessageChain[0]).toBe(msg1.id); + expect(activeMessageChain[1]).toBe(msg2.id); + expect(activeMessageChain[2]).toBe(msg3.id); + expect(activeMessageChain[3]).toBe(msg5.id); + expect(messages[msg4.id].revisionIds).toEqual([]); + expect(messages[msg4.id].responseIds).toEqual([]); + expect(messages[msg4.id].retryIds).toEqual([msg5.id]); + expect(messages[msg5.id].previousMessageId).toBe(msg3.id); + expect(messages[msg5.id].retryOfMessageId).toBe(msg4.id); + }); + + it('should get a complex conversation with multiple branches', async () => { + // This test creates a complex conversation with multiple edits and retries to ensure + // the conversation tree is built correctly in all cases. + + // The structure created is as follows: + // msg1 -> msg2 -> msg3a -> msg4a + // -> msg3b (edit of msg3a) -> msg4b + // msg1b (edit of msg1) -> nothing + // msg1 -> msg2r (retry of msg2) -> msg3d -> msg4c + + const ids = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; + + const session = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + }); + const msg1 = await messagesRepository.createChatMessage({ + id: ids[0], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 1', + turnId: ids[0], + createdAt: new Date('2025-01-03T00:00:00Z'), + }); + const msg2 = await messagesRepository.createChatMessage({ + id: ids[1], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 2a', + previousMessageId: msg1.id, + turnId: ids[0], + createdAt: new Date('2025-01-03T00:05:00Z'), + }); + const msg3a = await messagesRepository.createChatMessage({ + id: ids[2], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 3a', + previousMessageId: msg2.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:10:00Z'), + }); + const msg4a = await messagesRepository.createChatMessage({ + id: ids[3], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4a', + previousMessageId: msg3a.id, + turnId: ids[2], + createdAt: new Date('2025-01-03T00:15:00Z'), + }); + const msg3b = await messagesRepository.createChatMessage({ + id: ids[4], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 3b', + revisionOfMessageId: msg3a.id, + previousMessageId: msg2.id, + turnId: ids[4], + createdAt: new Date('2025-01-03T00:20:00Z'), + }); + const msg4b = await messagesRepository.createChatMessage({ + id: ids[5], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4b', + previousMessageId: msg3b.id, + turnId: ids[4], + createdAt: new Date('2025-01-03T00:25:00Z'), + }); + const msg1b = await messagesRepository.createChatMessage({ + id: ids[6], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 1b', + revisionOfMessageId: msg1.id, + turnId: ids[6], + createdAt: new Date('2025-01-03T00:30:00Z'), + }); + const msg2r = await messagesRepository.createChatMessage({ + id: ids[7], + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 2b', + previousMessageId: msg1.id, + retryOfMessageId: msg2.id, + turnId: ids[0], + createdAt: new Date('2025-01-03T00:35:00Z'), + }); + const msg3d = await messagesRepository.createChatMessage({ + id: ids[8], + sessionId: session.id, + name: 'Nathan', + type: 'human', + content: 'message 3d', + previousMessageId: msg2r.id, + turnId: ids[8], + createdAt: new Date('2025-01-03T00:40:00Z'), + }); + const msg4c = await messagesRepository.createChatMessage({ + id: crypto.randomUUID(), + sessionId: session.id, + name: 'ChatGPT', + type: 'ai', + content: 'message 4c', + previousMessageId: msg3d.id, + turnId: ids[8], + createdAt: new Date('2025-01-03T00:45:00Z'), + }); + + const response = await chatHubService.getConversation(member.id, session.id); + expect(response).toBeDefined(); + expect(response.session.id).toBe(session.id); + + const { + conversation: { rootIds, messages, activeMessageChain }, + } = response; + + expect(rootIds).toEqual([msg1.id, msg1b.id]); + expect(Object.keys(messages)).toHaveLength(10); + + expect(activeMessageChain).toHaveLength(4); + expect(activeMessageChain[0]).toBe(msg1.id); + expect(activeMessageChain[1]).toBe(msg2r.id); + expect(activeMessageChain[2]).toBe(msg3d.id); + expect(activeMessageChain[3]).toBe(msg4c.id); + + expect(messages[msg1.id].revisionIds).toEqual([msg1b.id]); + expect(messages[msg1b.id].responseIds).toEqual([]); + expect(messages[msg1b.id].retryIds).toEqual([]); + + expect(messages[msg2.id].revisionIds).toEqual([]); + expect(messages[msg2.id].responseIds).toEqual([msg3a.id, msg3b.id]); + expect(messages[msg2.id].retryIds).toEqual([msg2r.id]); + + expect(messages[msg3b.id].revisionIds).toEqual([]); + expect(messages[msg3b.id].responseIds).toEqual([msg4b.id]); + expect(messages[msg3b.id].retryIds).toEqual([]); + + expect(messages[msg4a.id].revisionIds).toEqual([]); + expect(messages[msg4a.id].responseIds).toEqual([]); + expect(messages[msg4a.id].retryIds).toEqual([]); + + expect(messages[msg2r.id].previousMessageId).toBe(msg1.id); + expect(messages[msg2r.id].retryOfMessageId).toBe(msg2.id); + }); + }); +}); diff --git a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts new file mode 100644 index 00000000000..5626971ecbd --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts @@ -0,0 +1,191 @@ +import type { ChatHubProvider, ChatHubMessageType, ChatHubMessageState } from '@n8n/api-types'; +import { ExecutionEntity, WithTimestamps, WorkflowEntity } from '@n8n/db'; +import { + Column, + Entity, + ManyToOne, + JoinColumn, + OneToMany, + type Relation, + PrimaryGeneratedColumn, +} from '@n8n/typeorm'; + +import type { ChatHubSession } from './chat-hub-session.entity'; + +@Entity({ name: 'chat_hub_messages' }) +export class ChatHubMessage extends WithTimestamps { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** + * ID of the chat session/conversation this message belongs to. + */ + @Column({ type: String }) + sessionId: string; + + /** + * The chat session/conversation this message belongs to. + */ + @ManyToOne('ChatHubSession', 'messages', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sessionId' }) + session: Relation; + + /** + * Type of the message, e.g. 'human', 'ai', 'system', 'tool', 'generic'. + */ + @Column({ type: 'varchar', length: 16 }) + type: ChatHubMessageType; + + /** + * Name of the actor that sent the message, e.g. 'AI', 'HTTP Tool', 'Nathan' etc + */ + @Column({ type: 'varchar', length: 128 }) + name: string; + + /** + * The main content of the message. Might be text, JSON, etc depending on the message type. + */ + @Column('text') + content: string; + + /** + * Enum value of the LLM provider that generated this message, e.g. 'openai', 'anthropic', 'google', 'n8n'. + * Human messages have this field set to NULL. + */ + @Column({ type: 'varchar', length: 16, nullable: true }) + provider: ChatHubProvider | null; + + /** + * The LLM model that generated this message (if applicable). + * Human messages have this field set to NULL. + */ + @Column({ type: 'varchar', length: 64, nullable: true }) + model: string | null; + + /** + * ID of a custom n8n agent workflow that produced this message (if applicable). + * Human messages have this field set to NULL. + */ + @Column({ type: 'varchar', length: 36, nullable: true }) + workflowId: string | null; + + /** + * Custom n8n agent workflow that produced this message (if applicable). + */ + @ManyToOne('WorkflowEntity', { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'workflowId' }) + workflow?: Relation | null; + + /** + * ID of an execution that produced this message (reset to null when the execution is deleted). + */ + @Column({ type: 'int', nullable: true }) + executionId: number | null; + + /** + * Execution that produced this message (reset to null when the execution is deleted) + */ + @ManyToOne('ExecutionEntity', { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'executionId' }) + execution?: Relation | null; + + /** + * ID of the previous message this message is a response to, NULL on the initial message. + */ + @Column({ type: String, nullable: true }) + previousMessageId: string | null; + + /** + * The previous message this message is a response to, NULL on the initial message. + */ + @ManyToOne('ChatHubMessage', (m: ChatHubMessage) => m.responses, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'previousMessageId' }) + previousMessage?: Relation | null; + + /** + * Messages that are responses to this message. This could branch out to multiple threads. + */ + @OneToMany('ChatHubMessage', (m: ChatHubMessage) => m.previousMessage) + responses?: Array>; + + /** + * Root message of a conversation turn (Human message + AI responses) + */ + @Column({ type: String }) + turnId: string; + + /** + * Message that began the turn, probably from the human/user. + */ + @ManyToOne('ChatHubMessage', (m: ChatHubMessage) => m.turnMessages, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'turnId' }) + turn?: Relation | null; + + /** + * All messages that are part of this turn (including the root message). + */ + @OneToMany('ChatHubMessage', (m: ChatHubMessage) => m.turn) + turnMessages?: Array>; + + /** + * ID of the message that this message is a retry of (if applicable). + */ + @Column({ type: String, nullable: true }) + retryOfMessageId: string | null; + + /** + * The message that this message is a retry of (if applicable). + */ + @ManyToOne('ChatHubMessage', (m: ChatHubMessage) => m.retries, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'retryOfMessageId' }) + retryOfMessage?: Relation | null; + + /** + * All messages that are retries of this message (if applicable). + */ + @OneToMany('ChatHubMessage', (m: ChatHubMessage) => m.retryOfMessage) + retries?: Array>; + + /** + * The nth time this message has been generated/retried within the turn (0 = first attempt). + */ + @Column({ type: 'int', default: 0 }) + runIndex: number; + + /** + * ID of the message that this message is a revision/edit of (if applicable). + */ + @Column({ type: String, nullable: true }) + revisionOfMessageId: string | null; + + /** + * The message that this message is a revision/edit of (if applicable). + */ + @ManyToOne('ChatHubMessage', (m: ChatHubMessage) => m.revisions, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'revisionOfMessageId' }) + revisionOfMessage?: Relation | null; + + /** + * All messages that are revisions/edits of this message (if applicable). + */ + @OneToMany('ChatHubMessage', (m: ChatHubMessage) => m.revisionOfMessage) + revisions?: Array>; + + /** + * State of the message, e.g. 'active', 'superseded', 'hidden', 'deleted'. + */ + @Column({ type: 'varchar', length: 16, default: 'active' }) + state: ChatHubMessageState; +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts new file mode 100644 index 00000000000..9c80931354e --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts @@ -0,0 +1,89 @@ +import { ChatHubProvider } from '@n8n/api-types'; +import { WithTimestamps, DateTimeColumn, User, CredentialsEntity, WorkflowEntity } from '@n8n/db'; +import { + Column, + Entity, + ManyToOne, + OneToMany, + JoinColumn, + PrimaryGeneratedColumn, +} from '@n8n/typeorm'; + +import type { ChatHubMessage } from './chat-hub-message.entity'; + +@Entity({ name: 'chat_hub_sessions' }) +export class ChatHubSession extends WithTimestamps { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** + * The title of the chat session/conversation. + * Auto-generated if not provided by the user after the initial AI responses. + */ + @Column({ type: 'varchar', length: 256 }) + title: string; + + /** + * ID of the user that owns this chat session. + */ + @Column({ type: String }) + ownerId: string; + + /** + * The user that owns this chat session. + */ + @ManyToOne('User', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'ownerId' }) + owner?: User; + + /* + * Timestamp of the last active message in the session. + * Used to sort chat sessions by recent activity. + */ + @DateTimeColumn({ nullable: true }) + lastMessageAt: Date | null; + + /* + * ID of the selected credential to use by default with the selected LLM provider (if applicable). + */ + @Column({ type: 'varchar', length: 36, nullable: true }) + credentialId: string | null; + + /** + * The selected credential to use by default with the selected LLM provider (if applicable). + */ + @ManyToOne('CredentialsEntity', { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'credentialId' }) + credential?: CredentialsEntity | null; + + /* + * Enum value of the LLM provider to use, e.g. 'openai', 'anthropic', 'google', 'n8n' (if applicable). + */ + @Column({ type: 'varchar', length: 16, nullable: true }) + provider: ChatHubProvider | null; + + /* + * LLM model to use from the provider (if applicable) + */ + @Column({ type: 'varchar', length: 64, nullable: true }) + model: string | null; + + /* + * ID of the custom n8n agent workflow to use (if applicable) + */ + @Column({ type: 'varchar', length: 36, nullable: true }) + workflowId: string | null; + + /** + * Custom n8n agent workflow to use (if applicable) + */ + @ManyToOne('WorkflowEntity', { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'workflowId' }) + workflow?: WorkflowEntity | null; + + /** + * All messages that belong to this chat session. + */ + @OneToMany('ChatHubMessage', 'session') + messages?: ChatHubMessage[]; +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index 009ad74fd2a..e1d22d138cb 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -1,7 +1,12 @@ -import type { ChatHubSendMessageRequest, ChatModelsResponse } from '@n8n/api-types'; +import { + ChatHubSendMessageRequest, + ChatModelsResponse, + ChatHubConversationsResponse, + ChatHubConversationResponse, +} from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { AuthenticatedRequest } from '@n8n/db'; -import { RestController, Post, Body, GlobalScope } from '@n8n/decorators'; +import { RestController, Post, Body, GlobalScope, Get } from '@n8n/decorators'; import type { Response } from 'express'; import { strict as assert } from 'node:assert'; @@ -16,6 +21,7 @@ export class ChatHubController { ) {} @Post('/models') + @GlobalScope('chatHub:message') async getModels( req: AuthenticatedRequest, _res: Response, @@ -37,17 +43,12 @@ export class ChatHubController { res.header('Cache-Control', 'no-cache'); res.flushHeaders(); - // TODO: Save human message to DB - - const replyId = crypto.randomUUID(); - this.logger.info(`Chat send request received: ${JSON.stringify(payload)}`); try { - await this.chatService.askN8n(res, req.user, { + await this.chatService.respondMessage(res, req.user, { ...payload, userId: req.user.id, - replyId, }); } catch (executionError: unknown) { assert(executionError instanceof Error); @@ -64,7 +65,7 @@ export class ChatHubController { JSON.stringify({ type: 'error', content: executionError.message, - id: replyId, + id: payload.replyId, }) + '\n', ); res.flush(); @@ -73,4 +74,22 @@ export class ChatHubController { if (!res.writableEnded) res.end(); } } + + @Get('/conversations') + @GlobalScope('chatHub:message') + async getConversations( + req: AuthenticatedRequest, + _res: Response, + ): Promise { + return await this.chatService.getConversations(req.user.id); + } + + @Get('/conversations/:id') + @GlobalScope('chatHub:message') + async getConversationMessages( + req: AuthenticatedRequest<{ id: string }>, + _res: Response, + ): Promise { + return await this.chatService.getConversation(req.user.id, req.params.id); + } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.module.ts b/packages/cli/src/modules/chat-hub/chat-hub.module.ts index b62d293f8b4..63ab0fe2e4d 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.module.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.module.ts @@ -26,6 +26,13 @@ export class ChatHubModule implements ModuleInterface { return { chatAccessEnabled }; } + async entities() { + const { ChatHubSession } = await import('./chat-hub-session.entity'); + const { ChatHubMessage } = await import('./chat-hub-message.entity'); + + return [ChatHubSession, ChatHubMessage]; + } + @OnShutdown() async shutdown() {} } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index a351d906200..a5f078f7cf8 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -2,7 +2,11 @@ import { PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubProvider, type ChatModelsResponse, + type ChatHubConversationsResponse, + type ChatHubConversationResponse, chatHubProviderSchema, + ChatHubMessageDto, + type ChatMessageId, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { @@ -20,6 +24,7 @@ import type { Response } from 'express'; import { AGENT_LANGCHAIN_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, + INodeCredentials, OperationalError, type IConnections, type INode, @@ -29,13 +34,18 @@ import { } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; -import type { ChatPayloadWithCredentials } from './chat-hub.types'; - +import { ActiveExecutions } from '@/active-executions'; +import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsHelper } from '@/credentials-helper'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { getBase } from '@/workflow-execute-additional-data'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; -import { CredentialsService } from '@/credentials/credentials.service'; + +import { ChatHubMessage } from './chat-hub-message.entity'; +import { ChatHubSession } from './chat-hub-session.entity'; +import type { ChatPayloadWithCredentials, MessageRecord } from './chat-hub.types'; +import { ChatHubMessageRepository } from './chat-message.repository'; +import { ChatHubSessionRepository } from './chat-session.repository'; @Service() export class ChatHubService { @@ -48,6 +58,9 @@ export class ChatHubService { private readonly workflowRepository: WorkflowRepository, private readonly projectRepository: ProjectRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly activeExecutions: ActiveExecutions, + private readonly sessionRepository: ChatHubSessionRepository, + private readonly messageRepository: ChatHubMessageRepository, ) {} async getModels( @@ -285,7 +298,57 @@ export class ChatHubService { return undefined; } - async askN8n(res: Response, user: User, payload: ChatPayloadWithCredentials) { + private getCredentialId(provider: ChatHubProvider, credentials: INodeCredentials): string | null { + switch (provider) { + case 'openai': + return credentials['openAiApi'].id; + case 'anthropic': + return credentials['anthropicApi'].id; + case 'google': + return credentials['googlePalmApi'].id; + default: + return null; + } + } + + async respondMessage(res: Response, user: User, payload: ChatPayloadWithCredentials) { + const existing = await this.sessionRepository.getOneById(payload.sessionId, user.id); + const turnId = payload.messageId; + + const usedModel = { + credentialId: this.getCredentialId(payload.model.provider, payload.credentials), + provider: payload.model.provider, + model: payload.model.model, + workflowId: payload.model.workflowId, + }; + + // TODO: we're now providing both replyId and messageId from the frontend, but we shouldn't. + + // TODO: Handle session ID conflicts better (different user, same ID) + let session: ChatHubSession; + if (existing) { + session = existing; + } else { + session = await this.sessionRepository.createChatSession({ + id: payload.sessionId, + ownerId: user.id, + title: 'New Chat', + ...usedModel, + }); + } + + await this.messageRepository.createChatMessage({ + id: payload.messageId, + sessionId: payload.sessionId, + type: 'human', + name: user.firstName || 'User', + state: 'active', + content: payload.message, + turnId, + previousMessageId: payload.previousMessageId ?? null, + ...usedModel, + }); + /* eslint-disable @typescript-eslint/naming-convention */ const nodes: INode[] = [ { @@ -303,26 +366,76 @@ export class ChatHubService { }, { parameters: { + promptType: 'define', + text: "={{ $('When chat message received').item.json.chatInput }}", options: { enableStreaming: true, }, }, type: AGENT_LANGCHAIN_NODE_TYPE, typeVersion: 3, - position: [200, 0], + position: [600, 0], id: uuidv4(), name: 'AI Agent', }, this.createModelNode(payload), + { + parameters: { + sessionIdType: 'customKey', + sessionKey: "={{ $('When chat message received').item.json.sessionId }}", + }, + type: '@n8n/n8n-nodes-langchain.memoryBufferWindow', + typeVersion: 1.3, + position: [500, 200], + id: uuidv4(), + name: 'Memory', + }, + { + parameters: { + mode: 'insert', + messages: { + messageValues: session.messages?.map((message) => { + const typeMap: Record = { + human: 'user', + ai: 'ai', + system: 'system', + }; + + // TODO: Tools ? + return { + type: typeMap[message.type] || 'system', + message: message.content, + hideFromUI: false, + }; + }), + }, + }, + type: '@n8n/n8n-nodes-langchain.memoryManager', + typeVersion: 1.1, + position: [200, 0], + id: uuidv4(), + name: 'Restore Chat Memory', + }, ]; const connections: IConnections = { 'When chat message received': { + main: [[{ node: 'Restore Chat Memory', type: 'main', index: 0 }]], + }, + 'Restore Chat Memory': { main: [[{ node: 'AI Agent', type: 'main', index: 0 }]], }, 'Chat Model': { ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]], }, + Memory: { + ai_memory: [ + [ + { node: 'AI Agent', type: 'ai_memory', index: 0 }, + { node: 'Restore Chat Memory', type: 'ai_memory', index: 0 }, + ], + ], + }, }; const workflow = await this.createChatWorkflow(user, payload.sessionId, nodes, connections); @@ -334,7 +447,7 @@ export class ChatHubService { }; /* eslint-enable @typescript-eslint/naming-convention */ - const startNodes: StartNodeData[] = [{ name: 'AI Agent', sourceData: null }]; + const startNodes: StartNodeData[] = [{ name: 'Restore Chat Memory', sourceData: null }]; const triggerToStartFrom: { name: string; data?: ITaskData; @@ -350,7 +463,7 @@ export class ChatHubService { [ { json: { - sessionId: payload.sessionId, + sessionId: `${payload.sessionId}-${payload.messageId}`, action: 'sendMessage', chatInput: payload.message, }, @@ -375,40 +488,42 @@ export class ChatHubService { true, res, ); - if (!executionId) { throw new OperationalError('There was a problem starting the chat execution.'); } - // TODO: The execution finishes after a while, how do we store the full AI response on the database? - // Is there a better way to listen for the execution to finish? - const onClose = async () => { - this.logger.debug(`Connection closed by client, execution ID: ${executionId}`); + const result = await this.activeExecutions.getPostExecutePromise(executionId); + if (!result) { + throw new OperationalError('There was a problem executing the chat workflow.'); + } - // TODO: we could maybe stop executions here if user disconnected early? - // if (execution && ['running', 'waiting'].includes(execution.status)) { - // await this.executionService.stop(executionId, [workflow.id]); - // } + const execution = await this.executionRepository.findWithUnflattenedData(executionId, [ + workflow.id, + ]); + if (!execution) { + throw new NotFoundError(`Could not find execution with ID ${executionId}`); + } - const execution = await this.executionRepository.findWithUnflattenedData(executionId, [ - workflow.id, - ]); - - // Persist the assistant message to the database - if (execution?.data?.resultData) { - // resultData is only available if the execution finished - const message = this.getMessage(execution); - this.logger.debug(`Assistant: ${message} (${payload.replyId})`); - } - }; - - res.on('close', onClose); - res.on('error', onClose); + const message = this.getMessage(execution); + if (message) { + await this.messageRepository.createChatMessage({ + id: payload.replyId, + sessionId: payload.sessionId, + type: 'ai', + name: 'AI', + content: message, + state: 'active', + turnId, + executionId: parseInt(execution.id, 10), + previousMessageId: payload.messageId, + ...usedModel, + }); + } } private createModelNode(payload: ChatPayloadWithCredentials): INode { const common = { - position: [80, 200] as [number, number], + position: [600, 200] as [number, number], id: uuidv4(), name: 'Chat Model', credentials: payload.credentials, @@ -452,4 +567,151 @@ export class ChatHubService { }; } } + + /** + * Get all conversations for a user + */ + async getConversations(userId: string): Promise { + const sessions = await this.sessionRepository.getManyByUserId(userId); + + return sessions.map((session) => ({ + id: session.id, + title: session.title, + ownerId: session.ownerId, + lastMessageAt: session.lastMessageAt?.toISOString() ?? null, + credentialId: session.credentialId, + provider: session.provider, + model: session.model, + workflowId: session.workflowId, + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString(), + })); + } + + /** + * Get a single conversation with messages and ready to render timeline of latest messages + * */ + async getConversation(userId: string, sessionId: string): Promise { + const session = await this.sessionRepository.getOneById(sessionId, userId); + if (!session) { + throw new NotFoundError('Chat session not found'); + } + + const messages = await this.messageRepository.getManyBySessionId(sessionId); + const messagesGraph: Record = + this.buildMessagesGraph(messages); + + const rootIds = messages.filter((r) => r.previousMessageId === null).map((r) => r.id); + const activeMessageChain = this.buildActiveMessageChain(messages); + + return { + session: { + id: session.id, + title: session.title, + ownerId: session.ownerId, + lastMessageAt: session.lastMessageAt?.toISOString() ?? null, + credentialId: session.credentialId, + provider: session.provider, + model: session.model, + workflowId: session.workflowId, + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString(), + }, + conversation: { + messages: messagesGraph, + rootIds, + activeMessageChain, + }, + }; + } + + private buildMessagesGraph(messages: ChatHubMessage[]) { + const messagesGraph: Record = {}; + + for (const message of messages) { + messagesGraph[message.id] = { + id: message.id, + sessionId: message.sessionId, + type: message.type, + name: message.name, + content: message.content, + provider: message.provider, + model: message.model, + workflowId: message.workflowId, + executionId: message.executionId, + state: message.state, + createdAt: message.createdAt.toISOString(), + updatedAt: message.updatedAt.toISOString(), + + previousMessageId: message.previousMessageId, + turnId: message.turnId, + retryOfMessageId: message.retryOfMessageId, + revisionOfMessageId: message.revisionOfMessageId, + runIndex: message.runIndex, + + responseIds: [], + retryIds: [], + revisionIds: [], + }; + } + + for (const node of Object.values(messagesGraph)) { + if (node.previousMessageId && messagesGraph[node.previousMessageId]) { + messagesGraph[node.previousMessageId].responseIds.push(node.id); + } + if (node.retryOfMessageId && messagesGraph[node.retryOfMessageId]) { + messagesGraph[node.retryOfMessageId].retryIds.push(node.id); + } + if (node.revisionOfMessageId && messagesGraph[node.revisionOfMessageId]) { + messagesGraph[node.revisionOfMessageId].revisionIds.push(node.id); + } + } + + const sortByRunThenTime = (first: ChatMessageId, second: ChatMessageId) => { + const a = messagesGraph[first]; + const b = messagesGraph[second]; + + if (a.runIndex !== b.runIndex) { + return a.runIndex - b.runIndex; + } + + if (a.createdAt !== b.createdAt) { + return a.createdAt < b.createdAt ? -1 : 1; + } + + return a.id < b.id ? -1 : 1; + }; + + for (const node of Object.values(messagesGraph)) { + node.responseIds.sort(sortByRunThenTime); + node.retryIds.sort(sortByRunThenTime); + node.revisionIds.sort(sortByRunThenTime); + } + return messagesGraph; + } + + private buildActiveMessageChain(messages: ChatHubMessage[]) { + const nodes = new Map(messages.map((m) => [m.id, m])); + const activeMessages = messages.filter((m) => m.state === 'active'); + + const visited = new Set(); + const activeMessageChain = []; + const latest = activeMessages[activeMessages.length - 1]; // Messages are sorted by createdAt + + let current = latest ? latest.id : null; + + while (current && !visited.has(current)) { + activeMessageChain.unshift(current); + visited.add(current); + current = nodes.get(current)?.previousMessageId ?? null; + } + + return activeMessageChain; + } + + async deleteAllSessions() { + const result = await this.sessionRepository.deleteAll(); + + return result; + } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index 768a357f8a7..b6e502744f0 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -7,6 +7,22 @@ export interface ChatPayloadWithCredentials { messageId: string; sessionId: string; replyId: string; + previousMessageId: string | null; model: ChatHubConversationModel; credentials: INodeCredentials; } + +export type ChatMessage = { + id: string; + message: string; + type: 'user' | 'ai' | 'system'; + createdAt: Date; +}; + +// From packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts +export type MessageRole = 'ai' | 'system' | 'user'; +export interface MessageRecord { + type: MessageRole; + message: string; + hideFromUI: boolean; +} diff --git a/packages/cli/src/modules/chat-hub/chat-message.repository.ts b/packages/cli/src/modules/chat-hub/chat-message.repository.ts new file mode 100644 index 00000000000..69357484770 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-message.repository.ts @@ -0,0 +1,38 @@ +import { withTransaction } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; + +import { ChatHubMessage } from './chat-hub-message.entity'; +import { ChatHubSessionRepository } from './chat-session.repository'; + +@Service() +export class ChatHubMessageRepository extends Repository { + constructor( + dataSource: DataSource, + private chatSessionRepository: ChatHubSessionRepository, + ) { + super(ChatHubMessage, dataSource.manager); + } + + async createChatMessage(message: Partial, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + const chatMessage = em.create(ChatHubMessage, message); + const saved = await em.save(chatMessage); + await this.chatSessionRepository.updateLastMessageAt(saved.sessionId, saved.createdAt, em); + return saved; + }); + } + + async deleteChatMessage(id: string, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + return await em.delete(ChatHubMessage, { id }); + }); + } + + async getManyBySessionId(sessionId: string) { + return await this.find({ + where: { sessionId }, + order: { createdAt: 'ASC', id: 'DESC' }, + }); + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-session.repository.ts b/packages/cli/src/modules/chat-hub/chat-session.repository.ts new file mode 100644 index 00000000000..6cd906685ec --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-session.repository.ts @@ -0,0 +1,69 @@ +import { withTransaction } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; + +import { ChatHubSession } from './chat-hub-session.entity'; + +@Service() +export class ChatHubSessionRepository extends Repository { + constructor(dataSource: DataSource) { + super(ChatHubSession, dataSource.manager); + } + + async createChatSession(session: Partial, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + const chatHubSession = em.create(ChatHubSession, session); + const saved = await em.save(chatHubSession); + return await em.findOneOrFail(ChatHubSession, { + where: { id: saved.id }, + relations: ['messages'], + }); + }); + } + + async updateLastMessageAt(id: string, lastMessageAt: Date, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + await em.update(ChatHubSession, { id }, { lastMessageAt }); + return await em.findOneOrFail(ChatHubSession, { + where: { id }, + relations: ['messages'], + }); + }); + } + + async updateChatTitle(id: string, title: string, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + await em.update(ChatHubSession, { id }, { title }); + return await em.findOneOrFail(ChatHubSession, { + where: { id }, + relations: ['messages'], + }); + }); + } + + async deleteChatHubSession(id: string, trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + return await em.delete(ChatHubSession, { id }); + }); + } + + async getManyByUserId(userId: string) { + return await this.find({ + where: { ownerId: userId }, + order: { lastMessageAt: 'DESC', id: 'ASC' }, + }); + } + + async getOneById(id: string, userId: string) { + return await this.findOne({ + where: { id, ownerId: userId }, + relations: ['messages'], + }); + } + + async deleteAll(trx?: EntityManager) { + return await withTransaction(this.manager, trx, async (em) => { + return await em.createQueryBuilder().delete().from(ChatHubSession).execute(); + }); + } +} diff --git a/packages/cli/src/modules/community-packages/community-node-types.controller.ts b/packages/cli/src/modules/community-packages/community-node-types.controller.ts index e8ea49da03c..1ce6e4a38bc 100644 --- a/packages/cli/src/modules/community-packages/community-node-types.controller.ts +++ b/packages/cli/src/modules/community-packages/community-node-types.controller.ts @@ -8,12 +8,12 @@ import { CommunityNodeTypesService } from './community-node-types.service'; export class CommunityNodeTypesController { constructor(private readonly communityNodeTypesService: CommunityNodeTypesService) {} - @Get('/:name') + @Get('/:name', { allowSkipPreviewAuth: true }) async getCommunityNodeType(req: Request): Promise { return await this.communityNodeTypesService.getCommunityNodeType(req.params.name); } - @Get('/') + @Get('/', { allowSkipPreviewAuth: true }) async getCommunityNodeTypes() { return await this.communityNodeTypesService.getCommunityNodeTypes(); } diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts index bb58355e432..08566d71332 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts @@ -1179,7 +1179,7 @@ describe('dataTable filters', () => { const createdAtTimestamp = inserted[0].createdAt; const midnight = new Date(createdAtTimestamp); - midnight.setHours(0, 0, 0, 0); + midnight.setUTCHours(0, 0, 0, 0); // ACT - Check the row is not returned if filtered before midnight const beforeMidnightResult = await dataTableService.getManyRowsAndCount( @@ -1208,7 +1208,7 @@ describe('dataTable filters', () => { expect(result.count).toBeGreaterThanOrEqual(1); expect(result.data.some((row) => row.name === 'TestRow')).toBe(true); - // ACT - - Check the row is returned when using lt on the exact timestamp + // ACT - Check the row is returned when using lt on the exact timestamp const resultLt = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { filter: { type: 'and', @@ -1219,6 +1219,114 @@ describe('dataTable filters', () => { // ASSERT expect(resultLt.data.some((row) => row.name === 'TestRow')).toBe(false); }); + + it('filters by date with timezone offset using eq condition', async () => { + // ARRANGE + const dateWithOffset = new Date('2024-01-15T10:30:00.000+03:00'); + const equivalentUtcTime = new Date('2024-01-15T07:30:00.000Z'); + + await dataTableService.insertRows( + dataTableId, + project.id, + [{ name: 'TimezoneTest', registeredAt: dateWithOffset }], + 'all', + ); + + // ACT + const resultWithOffset = await dataTableService.getManyRowsAndCount( + dataTableId, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'registeredAt', value: dateWithOffset, condition: 'eq' }], + }, + }, + ); + + const resultWithUtc = await dataTableService.getManyRowsAndCount( + dataTableId, + project.id, + { + filter: { + type: 'and', + filters: [ + { columnName: 'registeredAt', value: equivalentUtcTime, condition: 'eq' }, + ], + }, + }, + ); + + // ASSERT + expect(resultWithOffset.count).toBe(1); + expect(resultWithOffset.data[0].name).toBe('TimezoneTest'); + expect(resultWithUtc.count).toBe(1); + expect(resultWithUtc.data[0].name).toBe('TimezoneTest'); + }); + + it('filters by date finding multiple rows with same UTC time but different offsets', async () => { + // ARRANGE + const isoWithOffsetPlus = '2024-01-15T10:30:00.000+05:00'; + const isoWithOffsetMinus = '2024-01-15T02:30:00.000-03:00'; + const equivalentUtcTime = new Date('2024-01-15T05:30:00.000Z'); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'OffsetPlus', registeredAt: isoWithOffsetPlus }, + { name: 'OffsetMinus', registeredAt: isoWithOffsetMinus }, + ]); + + // ACT + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + filter: { + type: 'and', + filters: [{ columnName: 'registeredAt', value: equivalentUtcTime, condition: 'eq' }], + }, + }); + + // ASSERT + expect(result.count).toBe(2); + expect(result.data.map((r) => r.name).sort()).toEqual(['OffsetMinus', 'OffsetPlus']); + }); + + it('correctly compares dates across timezones with gt/lt filters', async () => { + // ARRANGE + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'TzEarly', registeredAt: new Date('2025-01-15T08:00:00.000+02:00') }, + { name: 'TzMiddle', registeredAt: new Date('2025-01-15T12:00:00.000Z') }, + { name: 'TzLate', registeredAt: new Date('2025-01-15T20:00:00.000+02:00') }, + ]); + + // ACT + const filterDate = new Date('2025-01-15T13:00:00.000+03:00'); + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + filter: { + type: 'and', + filters: [ + { columnName: 'registeredAt', value: filterDate, condition: 'gt' }, + { columnName: 'name', value: 'Tz%', condition: 'like' }, + ], + }, + }); + + // ASSERT + expect(result.count).toBe(2); + expect(result.data.map((r) => r.name).sort()).toEqual(['TzLate', 'TzMiddle']); + + // ACT + const resultLt = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + filter: { + type: 'and', + filters: [ + { columnName: 'registeredAt', value: filterDate, condition: 'lt' }, + { columnName: 'name', value: 'Tz%', condition: 'like' }, + ], + }, + }); + + // ASSERT + expect(resultLt.count).toBe(1); + expect(resultLt.data[0].name).toBe('TzEarly'); + }); }); describe('null value validation', () => { diff --git a/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts index ecfe346920d..c50e638486d 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts @@ -1359,6 +1359,52 @@ describe('dataTable', () => { ); }); + it('converts dates with timezone offsets to UTC when inserting', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project1.id, { + name: 'dataTable', + columns: [{ name: 'registeredAt', type: 'date' }], + }); + + const dateWithOffset = new Date('2024-01-15T10:30:00.000+03:00'); + const expectedUtcTime = new Date('2024-01-15T07:30:00.000Z'); + + // ACT + const inserted = await dataTableService.insertRows( + dataTableId, + project1.id, + [{ registeredAt: dateWithOffset }], + 'all', + ); + + // ASSERT + expect((inserted[0].registeredAt as Date).getTime()).toBe(expectedUtcTime.getTime()); + }); + + it('converts ISO date strings with timezone offsets to UTC when inserting', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project1.id, { + name: 'dataTable', + columns: [{ name: 'registeredAt', type: 'date' }], + }); + + const isoWithOffsetPlus = '2024-01-15T10:30:00.000+05:00'; + const isoWithOffsetMinus = '2024-01-15T02:30:00.000-03:00'; + const expectedUtcTime = new Date('2024-01-15T05:30:00.000Z'); + + // ACT + const inserted = await dataTableService.insertRows( + dataTableId, + project1.id, + [{ registeredAt: isoWithOffsetPlus }, { registeredAt: isoWithOffsetMinus }], + 'all', + ); + + // ASSERT + expect((inserted[0].registeredAt as Date).getTime()).toBe(expectedUtcTime.getTime()); + expect((inserted[1].registeredAt as Date).getTime()).toBe(expectedUtcTime.getTime()); + }); + it('rejects unknown data table id', async () => { // ARRANGE await dataTableService.createDataTable(project1.id, { diff --git a/packages/cli/src/modules/data-table/__tests__/sql-utils.test.ts b/packages/cli/src/modules/data-table/__tests__/sql-utils.test.ts index 02427804418..2719796b742 100644 --- a/packages/cli/src/modules/data-table/__tests__/sql-utils.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/sql-utils.test.ts @@ -1,6 +1,204 @@ -import { addColumnQuery, deleteColumnQuery } from '../utils/sql-utils'; +import type { DataTableColumnType } from 'n8n-workflow'; + +import type { DataTableColumn } from '../data-table-column.entity'; +import { + addColumnQuery, + deleteColumnQuery, + normalizeRows, + normalizeValueForDatabase, + toSqliteGlobFromPercent, +} from '../utils/sql-utils'; describe('sql-utils', () => { + describe('normalizeRows', () => { + const createColumn = (name: string, type: DataTableColumnType): DataTableColumn => + ({ + id: '1', + name, + type, + dataTableId: 'test-table', + createdAt: new Date(), + updatedAt: new Date(), + }) as DataTableColumn; + + it('should normalize boolean values from numbers', () => { + const columns = [createColumn('active', 'boolean')]; + const rows = [ + { id: 1, active: 1, createdAt: new Date(), updatedAt: new Date() }, + { id: 2, active: 0, createdAt: new Date(), updatedAt: new Date() }, + ]; + + const result = normalizeRows(rows, columns); + + expect(result[0].active).toEqual(true); + expect(result[1].active).toEqual(false); + }); + + it('should normalize boolean values from strings', () => { + const columns = [createColumn('active', 'boolean')]; + const rows = [ + { id: 1, active: '1', createdAt: new Date(), updatedAt: new Date() }, + { id: 2, active: '0', createdAt: new Date(), updatedAt: new Date() }, + ]; + + const result = normalizeRows(rows, columns); + + expect(result[0].active).toEqual(true); + expect(result[1].active).toEqual(false); + }); + + it('should keep boolean values as-is when already boolean', () => { + const columns = [createColumn('active', 'boolean')]; + const rows = [ + { id: 1, active: true, createdAt: new Date(), updatedAt: new Date() }, + { id: 2, active: false, createdAt: new Date(), updatedAt: new Date() }, + ]; + + const result = normalizeRows(rows, columns); + + expect(result[0].active).toEqual(true); + expect(result[1].active).toEqual(false); + }); + + it('should keep date values unchanged for Date objects', () => { + const columns = [createColumn('birthday', 'date')]; + const testDate = new Date('2024-01-15T10:30:00Z'); + const rows = [{ id: 1, birthday: testDate, createdAt: new Date(), updatedAt: new Date() }]; + + const result = normalizeRows(rows, columns); + + expect(result[0].birthday).toEqual(testDate); + }); + + it('should normalize date values from ISO strings', () => { + const columns = [createColumn('birthday', 'date')]; + const dateString = '2024-01-15T10:30:00Z'; + const rows = [{ id: 1, birthday: dateString, createdAt: dateString, updatedAt: dateString }]; + + const result = normalizeRows(rows, columns); + + expect(result[0].birthday).toEqual(new Date(dateString)); + expect(result[0].createdAt).toEqual(new Date(dateString)); + expect(result[0].updatedAt).toEqual(new Date(dateString)); + }); + + it('should normalize date values from strings of sqlite format', () => { + const columns = [createColumn('birthday', 'date')]; + const dateString = '2024-01-15 10:30:00'; + const rows = [{ id: 1, birthday: dateString, createdAt: dateString, updatedAt: dateString }]; + + const result = normalizeRows(rows, columns); + + expect(result[0].birthday).toEqual(new Date('2024-01-15T10:30:00Z')); + expect(result[0].createdAt).toEqual(new Date('2024-01-15T10:30:00Z')); + expect(result[0].updatedAt).toEqual(new Date('2024-01-15T10:30:00Z')); + }); + + it('should normalize date values from timestamps', () => { + const columns = [createColumn('birthday', 'date')]; + const timestamp = 1705318200000; // 2024-01-15T10:30:00Z + const rows = [{ id: 1, birthday: timestamp, createdAt: timestamp, updatedAt: timestamp }]; + + const result = normalizeRows(rows, columns); + + expect(result[0].birthday).toEqual(new Date(timestamp)); + expect(result[0].createdAt).toEqual(new Date(timestamp)); + expect(result[0].updatedAt).toEqual(new Date(timestamp)); + }); + + it('should handle invalid date strings gracefully', () => { + const columns = [createColumn('birthday', 'date')]; + const rows = [ + { id: 1, birthday: 'not-a-date', createdAt: new Date(), updatedAt: new Date() }, + ]; + + const result = normalizeRows(rows, columns); + + expect(result[0].birthday).toBe('not-a-date'); + }); + + it('should handle null date values', () => { + const columns = [createColumn('birthday', 'date')]; + const rows = [{ id: 1, birthday: null, createdAt: new Date(), updatedAt: new Date() }]; + + const result = normalizeRows(rows, columns); + + expect(result[0].birthday).toBeNull(); + }); + + it('should handle multiple rows', () => { + const columns = [ + createColumn('name', 'string'), + createColumn('age', 'number'), + createColumn('active', 'boolean'), + ]; + const date = new Date(); + const rows = [ + { + id: 1, + name: 'John Doe', + age: 30, + active: 1, + createdAt: date, + updatedAt: date, + }, + { + id: 2, + name: 'Jane Doe', + age: 25, + active: 0, + createdAt: date, + updatedAt: date, + }, + { + id: 3, + name: 'Jim Doe', + age: 35, + active: true, + createdAt: date, + updatedAt: date, + }, + ]; + + const result = normalizeRows(rows, columns); + + expect(result).toEqual([ + { + id: 1, + active: true, + age: 30, + name: 'John Doe', + createdAt: date, + updatedAt: date, + }, + { + id: 2, + active: false, + age: 25, + name: 'Jane Doe', + createdAt: date, + updatedAt: date, + }, + { + id: 3, + active: true, + age: 35, + name: 'Jim Doe', + createdAt: date, + updatedAt: date, + }, + ]); + }); + + it('should handle empty rows array', () => { + const columns = [createColumn('active', 'boolean')]; + + const result = normalizeRows([], columns); + + expect(result).toEqual([]); + }); + }); + describe('addColumnQuery', () => { it('should generate a valid SQL query for adding columns to a table, sqlite', () => { const tableName = 'data_table_user_abc'; @@ -49,4 +247,110 @@ describe('sql-utils', () => { expect(query).toBe('ALTER TABLE "data_table_user_abc" DROP COLUMN "email"'); }); }); + + describe('normalizeValueForDatabase', () => { + it('should return value unchanged for non-date column types', () => { + expect(normalizeValueForDatabase('test', 'string')).toBe('test'); + expect(normalizeValueForDatabase(123, 'number')).toBe(123); + expect(normalizeValueForDatabase(true, 'boolean')).toBe(true); + }); + + it('should return null for null', () => { + expect(normalizeValueForDatabase(null, 'string')).toBeNull(); + expect(normalizeValueForDatabase(null, 'number')).toBeNull(); + expect(normalizeValueForDatabase(null, 'boolean')).toBeNull(); + expect(normalizeValueForDatabase(null, 'date')).toBeNull(); + }); + + describe('date columns', () => { + it.each([ + ['sqlite', '2024-01-15 10:30:00.123'], + ['sqlite-pooled', '2024-01-15 10:30:00.123'], + ['mysql', '2024-01-15 10:30:00.123'], + ['mariadb', '2024-01-15 10:30:00.123'], + ['postgres', '2024-01-15T10:30:00.123Z'], + ] as const)('should format Date object for %s', (dbType, expected) => { + const result = normalizeValueForDatabase( + new Date('2024-01-15T10:30:00.123Z'), + 'date', + dbType, + ); + + expect(result).toBe(expected); + }); + + it.each([ + ['sqlite', '2024-01-15 10:30:00.123'], + ['sqlite-pooled', '2024-01-15 10:30:00.123'], + ['mysql', '2024-01-15 10:30:00.123'], + ['mariadb', '2024-01-15 10:30:00.123'], + ['postgres', '2024-01-15T10:30:00.123Z'], + ] as const)('should format ISO date string for %s', (dbType, expected) => { + const result = normalizeValueForDatabase('2024-01-15T10:30:00.123Z', 'date', dbType); + + expect(result).toBe(expected); + }); + + it('should throw on invalid date string', () => { + expect(() => normalizeValueForDatabase('not-a-date', 'date', 'sqlite')).toThrow( + 'Invalid date', + ); + }); + + it('should throw on invalid date value', () => { + expect(() => + normalizeValueForDatabase('2024-99-99T10:30:00.123Z', 'date', 'sqlite'), + ).toThrow('Invalid date'); + }); + + it('should throw for unsupported value types', () => { + expect(() => normalizeValueForDatabase(true, 'date')).toThrow( + 'Expected Date object or ISO date string', + ); + expect(() => normalizeValueForDatabase(false, 'date')).toThrow( + 'Expected Date object or ISO date string', + ); + expect(() => normalizeValueForDatabase(123, 'date')).toThrow( + 'Expected Date object or ISO date string', + ); + }); + }); + }); + + describe('toSqliteGlobFromPercent', () => { + it('should convert % to *', () => { + expect(toSqliteGlobFromPercent('test%')).toBe('test*'); + expect(toSqliteGlobFromPercent('%test')).toBe('*test'); + expect(toSqliteGlobFromPercent('%test%')).toBe('*test*'); + }); + + it('should escape [ with [[]', () => { + expect(toSqliteGlobFromPercent('test[abc')).toBe('test[[]abc'); + }); + + it('should escape ] with []]', () => { + expect(toSqliteGlobFromPercent('test]abc')).toBe('test[]]abc'); + }); + + it('should escape * with [*]', () => { + expect(toSqliteGlobFromPercent('test*abc')).toBe('test[*]abc'); + }); + + it('should escape ? with [?]', () => { + expect(toSqliteGlobFromPercent('test?abc')).toBe('test[?]abc'); + }); + + it('should handle multiple special characters', () => { + expect(toSqliteGlobFromPercent('%test*[abc]?%')).toBe('*test[*][[]abc[]][?]*'); + }); + + it('should handle empty string', () => { + expect(toSqliteGlobFromPercent('')).toBe(''); + }); + + it('should keep regular characters unchanged', () => { + expect(toSqliteGlobFromPercent('abc123')).toBe('abc123'); + expect(toSqliteGlobFromPercent('test_value')).toBe('test_value'); + }); + }); }); diff --git a/packages/cli/src/modules/data-table/data-table-column.repository.ts b/packages/cli/src/modules/data-table/data-table-column.repository.ts index 96c08b9053d..22bcde279a9 100644 --- a/packages/cli/src/modules/data-table/data-table-column.repository.ts +++ b/packages/cli/src/modules/data-table/data-table-column.repository.ts @@ -78,7 +78,6 @@ export class DataTableColumnRepository extends Repository { dataTableId, }); - // @ts-ignore Workaround for intermittent typecheck issue with _QueryDeepPartialEntity await em.insert(DataTableColumn, column); await this.ddlService.addColumn(dataTableId, column, em.connection.options.type, em); diff --git a/packages/cli/src/modules/data-table/data-table-rows.repository.ts b/packages/cli/src/modules/data-table/data-table-rows.repository.ts index 2d62793bbae..2f49387d040 100644 --- a/packages/cli/src/modules/data-table/data-table-rows.repository.ts +++ b/packages/cli/src/modules/data-table/data-table-rows.repository.ts @@ -21,6 +21,7 @@ import { DataTableInsertRowsReturnType, DataTableInsertRowsResult, DataTableRowReturnWithState, + DataTableRawRowReturn, } from 'n8n-workflow'; import { DataTableColumn } from './data-table-column.entity'; @@ -30,7 +31,7 @@ import { extractInsertedIds, extractReturningData, normalizeRows, - normalizeValue, + normalizeValueForDatabase, quoteIdentifier, toSqliteGlobFromPercent, toTableName, @@ -101,9 +102,9 @@ function resolvePath( function getConditionAndParams( filter: DataTableFilter['filters'][number], index: number, + columns: DataTableColumn[], dbType: DataSourceOptions['type'], tableReference?: string, - columns?: DataTableColumn[], ): [string, Record] { const paramName = `filter_${index}`; const columnRef = resolvePath( @@ -127,7 +128,7 @@ function getConditionAndParams( // Find the column type to normalize the value consistently const columnInfo = columns?.find((col) => col.name === filter.columnName); const value = columnInfo - ? normalizeValue(filter.value, columnInfo?.type, dbType, filter.path) + ? normalizeValueForDatabase(filter.value, columnInfo?.type, dbType, filter.path) : filter.value; // Handle operators that map directly to SQL operators @@ -251,7 +252,7 @@ export class DataTableRowsRepository { const column = columns[h]; // Fill missing columns with null values to support partial data insertion const value = rows[j][column.name] ?? null; - insertArray[h] = normalizeValue(value, column.type, dbType); + insertArray[h] = normalizeValueForDatabase(value, column.type, dbType); } completeRows[j - start] = insertArray; } @@ -309,7 +310,11 @@ export class DataTableRowsRepository { if (!(column.name in completeRow)) { completeRow[column.name] = null; } - completeRow[column.name] = normalizeValue(completeRow[column.name], column.type, dbType); + completeRow[column.name] = normalizeValueForDatabase( + completeRow[column.name], + column.type, + dbType, + ); } const query = em.createQueryBuilder().insert().into(table).values(completeRow); @@ -384,11 +389,11 @@ export class DataTableRowsRepository { affectedRows = await this.getAffectedRowsForUpdate(dataTableId, filter, columns, true, trx); } - setData.updatedAt = normalizeValue(new Date(), 'date', dbType); + setData.updatedAt = normalizeValueForDatabase(new Date(), 'date', dbType); const query = em.createQueryBuilder().update(table); // Some DBs (like SQLite) don't allow using table aliases as column prefixes in UPDATE statements - this.applyFilters(query, filter, undefined, columns); + this.applyFilters(query, columns, filter, undefined); query.set(setData); if (useReturning && returnData) { @@ -490,7 +495,7 @@ export class DataTableRowsRepository { // Just delete and return true const query = em.createQueryBuilder().delete().from(table, 'dataTable'); if (filter) { - this.applyFilters(query, filter, undefined, columns); + this.applyFilters(query, columns, filter, undefined); } await query.execute(); @@ -503,10 +508,10 @@ export class DataTableRowsRepository { const selectQuery = em.createQueryBuilder().select('*').from(table, 'dataTable'); if (filter) { - this.applyFilters(selectQuery, filter, 'dataTable', columns); + this.applyFilters(selectQuery, columns, filter, 'dataTable'); } - const rawRows = await selectQuery.getRawMany(); + const rawRows = await selectQuery.getRawMany(); affectedRows = normalizeRows(rawRows, columns); } @@ -527,7 +532,7 @@ export class DataTableRowsRepository { } if (filter) { - this.applyFilters(deleteQuery, filter, undefined, columns); + this.applyFilters(deleteQuery, columns, filter, undefined); } const result = await deleteQuery.execute(); @@ -551,7 +556,7 @@ export class DataTableRowsRepository { const table = toTableName(dataTableId); const selectColumns = idsOnly ? 'id' : '*'; const selectQuery = em.createQueryBuilder().select(selectColumns).from(table, 'dataTable'); - this.applyFilters(selectQuery, filter, 'dataTable', columns); + this.applyFilters(selectQuery, columns, filter, 'dataTable'); const rawRows: DataTableRowsReturn = await selectQuery.getRawMany(); if (idsOnly) { @@ -570,7 +575,7 @@ export class DataTableRowsRepository { const setData = { ...data }; for (const column of columns) { if (column.name in setData) { - setData[column.name] = normalizeValue(setData[column.name], column.type, dbType); + setData[column.name] = normalizeValueForDatabase(setData[column.name], column.type, dbType); } } return setData; @@ -611,15 +616,15 @@ export class DataTableRowsRepository { async getManyAndCount( dataTableId: string, + columns: DataTableColumn[], dto: ListDataTableContentQueryDto, - columns?: DataTableColumn[], trx?: EntityManager, ) { return await withTransaction( this.dataSource.manager, trx, async (em) => { - const [countQuery, query] = this.getManyQuery(dataTableId, dto, em, columns); + const [countQuery, query] = this.getManyQuery(dataTableId, columns, dto, em); const data: DataTableRowsReturn = await query.select('*').getRawMany(); const countResult = await countQuery.select('COUNT(*) as count').getRawOne<{ count: number | string | null; @@ -660,7 +665,7 @@ export class DataTableRowsRepository { .select(selectColumns) .from(table, 'dataTable') .where({ id: In(ids) }) - .getRawMany(); + .getRawMany(); return normalizeRows(rows, columns); }, @@ -670,16 +675,16 @@ export class DataTableRowsRepository { private getManyQuery( dataTableId: string, + columns: DataTableColumn[], dto: ListDataTableContentQueryDto, em: EntityManager, - columns?: DataTableColumn[], ): [QueryBuilder, QueryBuilder] { const query = em.createQueryBuilder(); const tableReference = 'dataTable'; query.from(toTableName(dataTableId), tableReference); if (dto.filter) { - this.applyFilters(query, dto.filter, tableReference, columns); + this.applyFilters(query, columns, dto.filter, tableReference); } const countQuery = query.clone().select('COUNT(*)'); this.applySorting(query, dto); @@ -690,16 +695,16 @@ export class DataTableRowsRepository { private applyFilters( query: SelectQueryBuilder | UpdateQueryBuilder | DeleteQueryBuilder, + columns: DataTableColumn[], filter: DataTableFilter, tableReference?: string, - columns?: DataTableColumn[], ): void { const filters = filter.filters ?? []; const filterType = filter.type ?? 'and'; const dbType = this.dataSource.options.type; const conditionsAndParams = filters.map((filter, i) => - getConditionAndParams(filter, i, dbType, tableReference, columns), + getConditionAndParams(filter, i, columns, dbType, tableReference), ); if (conditionsAndParams.length === 1) { diff --git a/packages/cli/src/modules/data-table/data-table-size-validator.service.ts b/packages/cli/src/modules/data-table/data-table-size-validator.service.ts index 4121d63d2ad..c705e633fc4 100644 --- a/packages/cli/src/modules/data-table/data-table-size-validator.service.ts +++ b/packages/cli/src/modules/data-table/data-table-size-validator.service.ts @@ -70,9 +70,13 @@ export class DataTableSizeValidator { } sizeToState(sizeBytes: number): DataTableSizeStatus { + const warningThreshold = + this.globalConfig.dataTable.warningThreshold ?? + Math.floor(0.8 * this.globalConfig.dataTable.maxSize); + if (sizeBytes >= this.globalConfig.dataTable.maxSize) { return 'error'; - } else if (sizeBytes >= this.globalConfig.dataTable.warningThreshold) { + } else if (sizeBytes >= warningThreshold) { return 'warn'; } return 'ok'; diff --git a/packages/cli/src/modules/data-table/data-table.repository.ts b/packages/cli/src/modules/data-table/data-table.repository.ts index e101d106291..b53f1883a32 100644 --- a/packages/cli/src/modules/data-table/data-table.repository.ts +++ b/packages/cli/src/modules/data-table/data-table.repository.ts @@ -41,7 +41,6 @@ export class DataTableRepository extends Repository { const dataTable = em.create(DataTable, { name, columns, projectId }); - // @ts-ignore Workaround for intermittent typecheck issue with _QueryDeepPartialEntity await em.insert(DataTable, dataTable); const dataTableId = dataTable.id; @@ -56,7 +55,6 @@ export class DataTableRepository extends Repository { ); if (columnEntities.length > 0) { - // @ts-ignore Workaround for intermittent typecheck issue with _QueryDeepPartialEntity await em.insert(DataTableColumn, columnEntities); } diff --git a/packages/cli/src/modules/data-table/data-table.service.ts b/packages/cli/src/modules/data-table/data-table.service.ts index c74e9317019..6b1f1f1d9ac 100644 --- a/packages/cli/src/modules/data-table/data-table.service.ts +++ b/packages/cli/src/modules/data-table/data-table.service.ts @@ -155,13 +155,13 @@ export class DataTableService { return await this.dataTableColumnRepository.manager.transaction(async (em) => { const columns = await this.dataTableColumnRepository.getColumns(dataTableId, em); - if (dto.filter) { - this.validateAndTransformFilters(dto.filter, columns); - } + const transformedDto = dto.filter + ? { ...dto, filter: this.validateAndTransformFilters(dto.filter, columns) } + : dto; const result = await this.dataTableRowsRepository.getManyAndCount( dataTableId, - dto, columns, + transformedDto, em, ); return { @@ -194,11 +194,11 @@ export class DataTableService { const result = await this.dataTableColumnRepository.manager.transaction(async (trx) => { const columns = await this.dataTableColumnRepository.getColumns(dataTableId, trx); - this.validateRowsWithColumns(rows, columns); + const transformedRows = this.validateAndTransformRows(rows, columns); return await this.dataTableRowsRepository.insertRows( dataTableId, - rows, + transformedRows, columns, returnType, trx, @@ -243,13 +243,13 @@ export class DataTableService { const result = await this.dataTableColumnRepository.manager.transaction(async (trx) => { const columns = await this.dataTableColumnRepository.getColumns(dataTableId, trx); - this.validateUpdateParams(dto, columns); + const { data, filter } = this.validateAndTransformUpdateParams(dto, columns); if (dryRun) { return await this.dataTableRowsRepository.dryRunUpsertRow( dataTableId, - dto.data, - dto.filter, + data, + filter, columns, trx, ); @@ -257,8 +257,8 @@ export class DataTableService { const updated = await this.dataTableRowsRepository.updateRows( dataTableId, - dto.data, - dto.filter, + data, + filter, columns, true, trx, @@ -271,7 +271,7 @@ export class DataTableService { // No rows were updated, so insert a new one const inserted = await this.dataTableRowsRepository.insertRows( dataTableId, - [dto.data], + [data], columns, returnData ? 'all' : 'id', trx, @@ -286,10 +286,10 @@ export class DataTableService { return result; } - validateUpdateParams( + validateAndTransformUpdateParams( { filter, data }: Pick, columns: DataTableColumn[], - ) { + ): { data: DataTableRow; filter: DataTableFilter } { if (columns.length === 0) { throw new DataTableValidationError( 'No columns found for this data table or data table not found', @@ -303,8 +303,10 @@ export class DataTableService { throw new DataTableValidationError('Data columns must not be empty'); } - this.validateRowsWithColumns([data], columns, false); - this.validateAndTransformFilters(filter, columns); + const [transformedData] = this.validateAndTransformRows([data], columns, false); + const transformedFilter = this.validateAndTransformFilters(filter, columns); + + return { data: transformedData, filter: transformedFilter }; } async updateRows( @@ -340,13 +342,13 @@ export class DataTableService { const result = await this.dataTableColumnRepository.manager.transaction(async (trx) => { const columns = await this.dataTableColumnRepository.getColumns(dataTableId, trx); - this.validateUpdateParams(dto, columns); + const { data, filter } = this.validateAndTransformUpdateParams(dto, columns); if (dryRun) { return await this.dataTableRowsRepository.dryRunUpdateRows( dataTableId, - dto.data, - dto.filter, + data, + filter, columns, trx, ); @@ -354,8 +356,8 @@ export class DataTableService { return await this.dataTableRowsRepository.updateRows( dataTableId, - dto.data, - dto.filter, + data, + filter, columns, returnData, trx, @@ -408,12 +410,12 @@ export class DataTableService { ); } - this.validateAndTransformFilters(dto.filter, columns); + const transformedFilter = this.validateAndTransformFilters(dto.filter, columns); return await this.dataTableRowsRepository.deleteRows( dataTableId, columns, - dto.filter, + transformedFilter, returnData, dryRun, trx, @@ -427,11 +429,12 @@ export class DataTableService { return result; } - private validateRowsWithColumns( + private validateAndTransformRows( rows: DataTableRows, columns: Array<{ name: string; type: DataTableColumnType }>, includeSystemColumns = false, - ): void { + skipDateTransform = false, + ): DataTableRows { // Include system columns like 'id' if requested const allColumns = includeSystemColumns ? [ @@ -444,28 +447,40 @@ export class DataTableService { : columns; const columnNames = new Set(allColumns.map((x) => x.name)); const columnTypeMap = new Map(allColumns.map((x) => [x.name, x.type])); - for (const row of rows) { + + return rows.map((row) => { + const transformedRow: DataTableRow = {}; const keys = Object.keys(row); for (const key of keys) { if (!columnNames.has(key)) { throw new DataTableValidationError(`unknown column name '${key}'`); } - this.validateCell(row, key, columnTypeMap); + transformedRow[key] = this.validateAndTransformCell( + row[key], + key, + columnTypeMap, + skipDateTransform, + ); } - } + return transformedRow; + }); } - private validateCell(row: DataTableRow, key: string, columnTypeMap: Map) { - const cell = row[key]; - if (cell === null) return; + private validateAndTransformCell( + cell: DataTableColumnJsType, + key: string, + columnTypeMap: Map, + skipDateTransform = false, + ): DataTableColumnJsType { + if (cell === null) return null; const columnType = columnTypeMap.get(key); - if (!columnType) return; + if (!columnType) return cell; const fieldType = columnTypeToFieldType[columnType]; - if (!fieldType) return; + if (!fieldType) return cell; - if (columnType === 'json') return; + if (columnType === 'json') return cell; const validationResult = validateFieldType(key, cell, fieldType, { strict: false, // Allow type coercion (e.g., string numbers to numbers) @@ -478,12 +493,14 @@ export class DataTableService { ); } - // Special handling for date type to convert from luxon DateTime to ISO string if (columnType === 'date') { + if (skipDateTransform && cell instanceof Date) { + return cell; + } try { - const dateInISO = (validationResult.newValue as DateTime).toISO(); - row[key] = dateInISO; - return; + // Convert to UTC to ensure consistent timezone handling + const dateInISO = (validationResult.newValue as DateTime).toUTC().toISO(); + return dateInISO; } catch { throw new DataTableValidationError( `value '${String(cell)}' does not match column type 'date'`, @@ -491,7 +508,7 @@ export class DataTableService { } } - row[key] = validationResult.newValue as DataTableColumnJsType; + return validationResult.newValue as DataTableColumnJsType; } private async validateDataTableExists(dataTableId: string, projectId: string) { @@ -536,8 +553,9 @@ export class DataTableService { private validateAndTransformFilters( filterObject: DataTableFilter, columns: DataTableColumn[], - ): void { - this.validateRowsWithColumns( + ): DataTableFilter { + // Skip date transformation for filters - TypeORM needs Date objects for parameterized queries + const transformedRows = this.validateAndTransformRows( filterObject.filters.map((f) => { return { [f.columnName]: f.value, @@ -545,34 +563,43 @@ export class DataTableService { }), columns, true, + true, ); - for (const filter of filterObject.filters) { + const transformedFilters = filterObject.filters.map((filter, index) => { + const transformedValue = transformedRows[index][filter.columnName]; + if (['like', 'ilike'].includes(filter.condition)) { - if (filter.value === null || filter.value === undefined) { + if (transformedValue === null || transformedValue === undefined) { throw new DataTableValidationError( `${filter.condition.toUpperCase()} filter value cannot be null or undefined`, ); } - if (typeof filter.value !== 'string') { + if (typeof transformedValue !== 'string') { throw new DataTableValidationError( `${filter.condition.toUpperCase()} filter value must be a string`, ); } - if (!filter.value.includes('%')) { - filter.value = `%${filter.value}%`; - } + const valueWithWildcards = transformedValue.includes('%') + ? transformedValue + : `%${transformedValue}%`; + + return { ...filter, value: valueWithWildcards }; } if (['gt', 'gte', 'lt', 'lte'].includes(filter.condition)) { - if (filter.value === null || filter.value === undefined) { + if (transformedValue === null || transformedValue === undefined) { throw new DataTableValidationError( `${filter.condition.toUpperCase()} filter value cannot be null or undefined`, ); } } - } + + return { ...filter, value: transformedValue }; + }); + + return { ...filterObject, filters: transformedFilters }; } private async validateDataTableSize() { diff --git a/packages/cli/src/modules/data-table/utils/sql-utils.ts b/packages/cli/src/modules/data-table/utils/sql-utils.ts index 6acb20e8421..b9fc0efbce2 100644 --- a/packages/cli/src/modules/data-table/utils/sql-utils.ts +++ b/packages/cli/src/modules/data-table/utils/sql-utils.ts @@ -10,16 +10,17 @@ import type { DataSourceOptions } from '@n8n/typeorm'; import type { DataTableColumnJsType, DataTableColumnType, + DataTableRawRowsReturn, DataTableRowReturn, DataTableRowsReturn, } from 'n8n-workflow'; -import { UnexpectedError, UserError } from 'n8n-workflow'; - -import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP, UnexpectedError, UserError } from 'n8n-workflow'; import type { DataTableColumn } from '../data-table-column.entity'; import type { DataTableUserTableName } from '../data-table.types'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + export function toDslColumns(columns: DataTableCreateColumnSchema[]): DslColumn[] { return columns.map((col) => { const name = new DslColumn(col.name.trim()); @@ -192,19 +193,46 @@ export function extractInsertedIds(raw: unknown, dbType: DataSourceOptions['type } } -export function normalizeRows(rows: DataTableRowsReturn, columns: DataTableColumn[]) { - // we need to normalize system dates as well - const systemColumns = [ - { name: 'createdAt', type: 'date' }, - { name: 'updatedAt', type: 'date' }, - ]; +// Convert date objects or strings to dates in UTC +function normalizeDate(value: DataTableColumnJsType): Date | null { + if (value instanceof Date) return value; - const typeMap = new Map([...columns, ...systemColumns].map((col) => [col.name, col.type])); - // eslint-disable-next-line complexity + if (typeof value === 'string') { + // sqlite returns date strings without timezone information, but we store them as UTC + const parsed = new Date(value.endsWith('Z') ? value : value + 'Z'); + if (!isNaN(parsed.getTime())) return parsed; + } + + if (typeof value === 'number') { + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) return parsed; + } + + return null; +} + +// Normalize rows fetched from the database according to the column types +export function normalizeRows( + rows: DataTableRawRowsReturn, + columns: DataTableColumn[], +): DataTableRowsReturn { + const typeMap: Record = { + ...Object.fromEntries(columns.map((col) => [col.name, col.type])), + // we need to normalize system dates as well + ...DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP, + }; return rows.map((row) => { - const normalized = { ...row }; - for (const [key, value] of Object.entries(row)) { - const type = typeMap.get(key); + const { id, createdAt, updatedAt, ...rest } = row; + + const normalized: DataTableRowReturn = { + ...rest, + id, + createdAt: normalizeDate(createdAt) ?? new Date(), // fallback should not happen + updatedAt: normalizeDate(updatedAt) ?? new Date(), // fallback should not happen + }; + + for (const [key, value] of Object.entries(rest)) { + const type = typeMap[key]; if (type === 'json') { try { @@ -226,64 +254,60 @@ export function normalizeRows(rows: DataTableRowsReturn, columns: DataTableColum normalized[key] = false; } } + if (type === 'date' && value !== null && value !== undefined) { - // Convert date objects or strings to dates in UTC - let dateObj: Date | null = null; - - if (value instanceof Date) { - dateObj = value; - } else if (typeof value === 'string') { - // sqlite returns date strings without timezone information, but we store them as UTC - const parsed = new Date(value.endsWith('Z') ? value : value + 'Z'); - if (!isNaN(parsed.getTime())) { - dateObj = parsed; - } - } else if (typeof value === 'number') { - const parsed = new Date(value); - if (!isNaN(parsed.getTime())) { - dateObj = parsed; - } - } - - normalized[key] = dateObj ?? value; + normalized[key] = normalizeDate(value) ?? value; // fallback to original value } } return normalized; }); } -function formatDateForDatabase(date: Date, dbType?: DataSourceOptions['type']): string { - // MySQL/MariaDB DATETIME format doesn't accept ISO strings with 'Z' timezone - if (dbType === 'mysql' || dbType === 'mariadb') { +/** + * Format a date value (Date object or ISO string) for database storage. + * Converts to database-specific format. + */ +function formatDateForDatabase( + value: DataTableColumnJsType, + dbType?: DataSourceOptions['type'], +): string { + let date: Date; + + if (value instanceof Date) { + date = value; + } else if (typeof value === 'string') { + date = new Date(value); + } else { + throw new UnexpectedError( + `Expected Date object or ISO date string, got ${typeof value}: ${String(value)}`, + ); + } + + if (isNaN(date.getTime())) { + throw new UnexpectedError(`Invalid date: ${String(value)}`); + } + + // These dbs use DATETIME format without 'T' and 'Z' + if (dbType && ['sqlite', 'sqlite-pooled', 'mysql', 'mariadb'].includes(dbType)) { return date.toISOString().replace('T', ' ').replace('Z', ''); } - // PostgreSQL and SQLite accept ISO strings + return date.toISOString(); } -export function normalizeValue( +/** + * Normalize a value for database operations based on column type. + * For date columns, accepts both Date objects and ISO date strings. + * Converts them to database-specific format. + */ +export function normalizeValueForDatabase( value: DataTableColumnJsType, columnType: DataTableColumnType | undefined, dbType?: DataSourceOptions['type'], path?: string, ): DataTableColumnJsType { - if (value === null || value === undefined) { - return value; - } - - if (columnType === 'date') { - // Convert Date objects to appropriate string format for database parameter binding - if (value instanceof Date) { - return formatDateForDatabase(value, dbType); - } - - if (typeof value === 'string') { - const date = new Date(value); - if (!isNaN(date.getTime())) { - // Convert parsed date strings to appropriate format - return formatDateForDatabase(date, dbType); - } - } + if (columnType === 'date' && value !== null) { + return formatDateForDatabase(value, dbType); } if (columnType === 'json') { @@ -310,6 +334,7 @@ export function normalizeValue( return JSON.stringify(value); } } + return value; } diff --git a/packages/cli/src/modules/insights/__tests__/insights-bigint-migration.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights-bigint-migration.integration.test.ts new file mode 100644 index 00000000000..769b8f301f2 --- /dev/null +++ b/packages/cli/src/modules/insights/__tests__/insights-bigint-migration.integration.test.ts @@ -0,0 +1,343 @@ +import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; +import { DateTime } from 'luxon'; + +import { InsightsRawRepository } from '@/modules/insights/database/repositories/insights-raw.repository'; + +import { + createRawInsightsEvent, + createRawInsightsEvents, +} from '../database/entities/__tests__/db-utils'; +import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; +import { InsightsCompactionService } from '../insights-compaction.service'; + +beforeAll(async () => { + await testModules.loadModules(['insights']); + await testDb.init(); +}); + +beforeEach(async () => { + await testDb.truncate([ + 'InsightsRaw', + 'InsightsByPeriod', + 'InsightsMetadata', + 'WorkflowEntity', + 'Project', + ]); +}); + +// Terminate DB once after all tests complete +afterAll(async () => { + await testDb.terminate(); +}); + +describe('BigInt migration validation', () => { + describe('Store value exceeding 32-bit integer maximum', () => { + test('should store and retrieve values larger than 2^31', async () => { + // ARRANGE + const insightsRawRepository = Container.get(InsightsRawRepository); + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + // Values exceeding 32-bit signed integer maximum (2,147,483,647) + const largeValue1 = 2_147_483_648; // 2^31 + const largeValue2 = 5_000_000_000; + + // ACT + const event1 = await createRawInsightsEvent(workflow, { + type: 'success', + value: largeValue1, + timestamp: DateTime.utc(), + }); + + const event2 = await createRawInsightsEvent(workflow, { + type: 'success', + value: largeValue2, + timestamp: DateTime.utc(), + }); + + // ASSERT + // Verify the events were stored with exact values (no overflow) + expect(event1.value).toBe(largeValue1); + expect(event2.value).toBe(largeValue2); + + // Verify retrieval from database returns exact values + const retrievedEvents = await insightsRawRepository.find(); + expect(retrievedEvents).toHaveLength(2); + + const retrieved1 = retrievedEvents.find((e) => e.id === event1.id); + const retrieved2 = retrievedEvents.find((e) => e.id === event2.id); + + expect(retrieved1?.value).toBe(largeValue1); + expect(retrieved2?.value).toBe(largeValue2); + }); + }); + + describe('Compaction sum exceeding 32-bit integer maximum', () => { + test('should correctly sum large values during compaction without overflow', async () => { + // ARRANGE + const insightsCompactionService = Container.get(InsightsCompactionService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + // Create 3 events with large values that sum to exceed 32-bit max + // 1,800,000,000 * 3 = 5,400,000,000 (exceeds 2^31 - 1 = 2,147,483,647) + const eventValue = 1_800_000_000; + const expectedSum = 5_400_000_000; + + const timestamp = DateTime.utc().startOf('hour'); + + // Create 3 events in the same hour period for the same workflow + const events = [ + { type: 'success' as const, value: eventValue, timestamp }, + { type: 'success' as const, value: eventValue, timestamp: timestamp.plus({ minutes: 10 }) }, + { type: 'success' as const, value: eventValue, timestamp: timestamp.plus({ minutes: 20 }) }, + ]; + + await createRawInsightsEvents(workflow, events); + + // ACT + await insightsCompactionService.compactRawToHour(); + + // ASSERT + // Verify raw events are compacted (removed) + await expect(insightsRawRepository.count()).resolves.toBe(0); + + // Verify compacted event has correct sum (no overflow) + const compactedEvents = await insightsByPeriodRepository.find(); + expect(compactedEvents).toHaveLength(1); + expect(compactedEvents[0].value).toBe(expectedSum); + expect(compactedEvents[0].type).toBe('success'); + }); + }); + + describe('Maximum safe integer boundary validation', () => { + test('should handle Number.MAX_SAFE_INTEGER and arithmetic operations', async () => { + // ARRANGE + const insightsCompactionService = Container.get(InsightsCompactionService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + // Maximum safe integer in JavaScript (2^53 - 1 = 9,007,199,254,740,991) + const maxSafeInteger = Number.MAX_SAFE_INTEGER; + + const timestamp = DateTime.utc().startOf('hour'); + + // ACT - Test storing MAX_SAFE_INTEGER + const event = await createRawInsightsEvent(workflow, { + type: 'runtime_ms', + value: maxSafeInteger, + timestamp, + }); + + // ASSERT - Verify exact storage and retrieval + expect(event.value).toBe(maxSafeInteger); + + const retrieved = await insightsRawRepository.findOne({ where: { id: event.id } }); + expect(retrieved?.value).toBe(maxSafeInteger); + + // Clean up for next test + await testDb.truncate(['InsightsRaw', 'InsightsByPeriod', 'InsightsMetadata']); + + // ACT - Test arithmetic with large values (50% of MAX_SAFE_INTEGER each) + const workflow2 = await createWorkflow({}, project); + const halfMaxSafe = Math.floor(Number.MAX_SAFE_INTEGER / 2); + + await createRawInsightsEvents(workflow2, [ + { type: 'time_saved_min', value: halfMaxSafe, timestamp }, + { type: 'time_saved_min', value: halfMaxSafe, timestamp: timestamp.plus({ minutes: 5 }) }, + ]); + + await insightsCompactionService.compactRawToHour(); + + // ASSERT - Verify compaction sum is correct + await expect(insightsRawRepository.count()).resolves.toBe(0); + const compacted = await insightsByPeriodRepository.find(); + expect(compacted).toHaveLength(1); + + // Sum should be close to MAX_SAFE_INTEGER (allowing for floor rounding) + expect(compacted[0].value).toBe(halfMaxSafe * 2); + expect(compacted[0].value).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER); + }); + + test('should demonstrate precision loss for values exceeding MAX_SAFE_INTEGER', async () => { + // ARRANGE + const insightsRawRepository = Container.get(InsightsRawRepository); + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const timestamp = DateTime.utc(); + + // JavaScript Number type cannot safely represent integers beyond MAX_SAFE_INTEGER + // MAX_SAFE_INTEGER = 9,007,199,254,740,991 (2^53 - 1) + const maxSafeInteger = Number.MAX_SAFE_INTEGER; // 9,007,199,254,740,991 + + // Values BEYOND MAX_SAFE_INTEGER will experience precision loss in JavaScript + // Note: Database stores as bigint (no precision loss), but JS Number loses precision + // Beyond MAX_SAFE_INTEGER, consecutive integers cannot be represented uniquely + const unsafeValue1 = maxSafeInteger + 1; // Should be 9,007,199,254,740,992 + const unsafeValue2 = maxSafeInteger + 2; // Should be 9,007,199,254,740,993 + + // ASSERT - Demonstrate precision loss BEFORE storing + // Both values are NOT safe integers (precision cannot be guaranteed) + expect(Number.isSafeInteger(unsafeValue1)).toBe(false); + expect(Number.isSafeInteger(unsafeValue2)).toBe(false); + + // Critical demonstration: JavaScript rounds both values to the SAME number + // This proves precision loss - two different values become identical + expect(unsafeValue1).toBe(unsafeValue2); + expect(unsafeValue1).toBe(9007199254740992); // Both round to MAX_SAFE_INTEGER + 1 + + // ACT - Store values that exceed MAX_SAFE_INTEGER + const event1 = await createRawInsightsEvent(workflow, { + type: 'runtime_ms', + value: unsafeValue1, + timestamp, + }); + + const event2 = await createRawInsightsEvent(workflow, { + type: 'runtime_ms', + value: unsafeValue2, + timestamp: timestamp.plus({ seconds: 10 }), + }); + + // ASSERT - Stored values are identical (due to JS precision loss) + expect(event1.value).toBe(event2.value); // Both are 9007199254740992 + expect(event1.value).toBe(9007199254740992); + expect(event2.value).toBe(9007199254740992); + + // Retrieve from database - values remain identical due to JS Number conversion + const retrievedEvents = await insightsRawRepository.find({ order: { id: 'ASC' } }); + expect(retrievedEvents).toHaveLength(2); + + // Retrieved values are also identical (demonstrating persistent precision loss) + expect(retrievedEvents[0].value).toBe(retrievedEvents[1].value); + expect(retrievedEvents[0].value).toBe(9007199254740992); + expect(retrievedEvents[1].value).toBe(9007199254740992); + + // Both retrieved values are NOT safe integers + expect(Number.isSafeInteger(retrievedEvents[0].value)).toBe(false); + expect(Number.isSafeInteger(retrievedEvents[1].value)).toBe(false); + + // IMPORTANT: This test documents the current limitation. + // Two distinct values (MAX_SAFE_INTEGER + 1 and MAX_SAFE_INTEGER + 2) + // become indistinguishable due to JavaScript Number precision limits. + // + // To properly handle values > MAX_SAFE_INTEGER, we would need: + // 1. TypeORM transformer to convert bigint ↔ BigInt (not Number) + // 2. Application-level validation to reject values > MAX_SAFE_INTEGER + // 3. OR: Change entity type from 'number' to 'bigint' with proper transformers + }); + }); + + describe('Migration preserves existing small values', () => { + test('should correctly store and compact small integer values', async () => { + // ARRANGE + const insightsCompactionService = Container.get(InsightsCompactionService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const smallValue1 = 42; + const smallValue2 = 1_000_000; + const expectedSum = smallValue1 + smallValue2; + + const timestamp = DateTime.utc().startOf('hour'); + + // ACT + await createRawInsightsEvents(workflow, [ + { type: 'success', value: smallValue1, timestamp }, + { type: 'success', value: smallValue2, timestamp: timestamp.plus({ minutes: 15 }) }, + ]); + + // ASSERT - Verify retrieval of small values + const rawEvents = await insightsRawRepository.find({ order: { id: 'ASC' } }); + expect(rawEvents).toHaveLength(2); + expect(rawEvents[0].value).toBe(smallValue1); + expect(rawEvents[1].value).toBe(smallValue2); + + // ACT - Compact the events + await insightsCompactionService.compactRawToHour(); + + // ASSERT - Verify compaction sum is correct + await expect(insightsRawRepository.count()).resolves.toBe(0); + const compacted = await insightsByPeriodRepository.find(); + expect(compacted).toHaveLength(1); + expect(compacted[0].value).toBe(expectedSum); + }); + }); + + describe('Negative large values', () => { + test('should handle negative values exceeding 32-bit signed integer minimum', async () => { + // ARRANGE + const insightsRawRepository = Container.get(InsightsRawRepository); + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + // Values below 32-bit signed integer minimum (-2,147,483,648) + const negativeValue1 = -2_147_483_649; // Below 2^31 + const negativeValue2 = -5_000_000_000; + + // ACT + const event1 = await createRawInsightsEvent(workflow, { + type: 'time_saved_min', + value: negativeValue1, + timestamp: DateTime.utc(), + }); + + const event2 = await createRawInsightsEvent(workflow, { + type: 'time_saved_min', + value: negativeValue2, + timestamp: DateTime.utc(), + }); + + // ASSERT - Verify storage and retrieval of negative large values + expect(event1.value).toBe(negativeValue1); + expect(event2.value).toBe(negativeValue2); + + const retrievedEvents = await insightsRawRepository.find({ order: { id: 'ASC' } }); + expect(retrievedEvents).toHaveLength(2); + expect(retrievedEvents[0].value).toBe(negativeValue1); + expect(retrievedEvents[1].value).toBe(negativeValue2); + }); + + test('should correctly compact mixed positive and negative large values', async () => { + // ARRANGE + const insightsCompactionService = Container.get(InsightsCompactionService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const timestamp = DateTime.utc().startOf('hour'); + + // Mix of large positive and negative values + const positiveValue = 3_000_000_000; + const negativeValue = -2_500_000_000; + const expectedSum = positiveValue + negativeValue; // = 500,000,000 + + // ACT + await createRawInsightsEvents(workflow, [ + { type: 'runtime_ms', value: positiveValue, timestamp }, + { type: 'runtime_ms', value: negativeValue, timestamp: timestamp.plus({ minutes: 10 }) }, + ]); + + await insightsCompactionService.compactRawToHour(); + + // ASSERT + await expect(insightsRawRepository.count()).resolves.toBe(0); + const compacted = await insightsByPeriodRepository.find(); + expect(compacted).toHaveLength(1); + expect(compacted[0].value).toBe(expectedSum); + }); + }); +}); diff --git a/packages/cli/src/modules/insights/__tests__/insights-by-period-migration.test.ts b/packages/cli/src/modules/insights/__tests__/insights-by-period-migration.test.ts new file mode 100644 index 00000000000..344df4d64cb --- /dev/null +++ b/packages/cli/src/modules/insights/__tests__/insights-by-period-migration.test.ts @@ -0,0 +1,163 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + testModules, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; + +import { BOUNDARY_TEST_VALUES, insertPreMigrationPeriodData } from './migration-test-setup'; + +const MIGRATION_NAME = 'ChangeValueTypesForInsights1759399811000'; + +describe('ChangeValueTypesForInsights - insights_by_period table', () => { + let dataSource: DataSource; + + beforeAll(async () => { + await testModules.loadModules(['insights']); + + // Initialize DB connection without running migrations + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + + dataSource = Container.get(DataSource); + + // Run migrations up to (but not including) target migration + await initDbUpToMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + describe('Schema Migration', () => { + it('should change value column from INT to BIGINT', async () => { + // Create migration context for schema queries + const context = createTestMigrationContext(dataSource); + + // Create prerequisite data for foreign keys using direct SQL + const projectTableName = context.escape.tableName('project'); + await context.queryRunner.query( + `INSERT INTO ${projectTableName} (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`, + ['test-project-id', 'Test Project', 'personal', new Date(), new Date()], + ); + + const workflowTableName = context.escape.tableName('workflow_entity'); + await context.queryRunner.query( + `INSERT INTO ${workflowTableName} (id, name, active, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`, + ['test-workflow-id', 'Test Workflow', false, new Date(), new Date()], + ); + + // Insert test metadata (required for foreign key) + const metaTableName = context.escape.tableName('insights_metadata'); + await context.queryRunner.query( + `INSERT INTO ${metaTableName} (workflowId, projectId, workflowName, projectName) VALUES (?, ?, ?, ?)`, + ['test-workflow-id', 'test-project-id', 'Test Workflow', 'Test Project'], + ); + const [metaRow] = await context.queryRunner.query( + `SELECT metaId FROM ${metaTableName} LIMIT 1`, + ); + const metaId = metaRow.metaId; + + // Insert test data in old INT schema + const testValues = [ + BOUNDARY_TEST_VALUES.zero, + BOUNDARY_TEST_VALUES.negativeOne, + BOUNDARY_TEST_VALUES.positiveOne, + BOUNDARY_TEST_VALUES.intMax, + BOUNDARY_TEST_VALUES.intMin, + ]; + await insertPreMigrationPeriodData(context, metaId, testValues); + + // Capture data before migration using SQL + const insightsByPeriodTableName = context.escape.tableName('insights_by_period'); + const beforeData = await context.queryRunner.query( + `SELECT id, metaId, value FROM ${insightsByPeriodTableName} ORDER BY id ASC`, + ); + expect(beforeData).toHaveLength(testValues.length); + + // Run the migration + await runSingleMigration(MIGRATION_NAME); + + // Release old query runner before creating new one + await context.queryRunner.release(); + + // Create fresh context after migration (dataSource is reinitialized) + const postMigrationContext = createTestMigrationContext(dataSource); + + // Verify schema change based on database type + if (postMigrationContext.isSqlite) { + const result = await postMigrationContext.queryRunner.query( + `PRAGMA table_info(${insightsByPeriodTableName})`, + ); + const valueColumn = result.find((col: { name: string }) => col.name === 'value'); + expect(valueColumn).toBeDefined(); + expect(valueColumn.type.toLowerCase()).toContain('bigint'); + } else if (postMigrationContext.isPostgres) { + const result = await postMigrationContext.queryRunner.query( + `SELECT data_type FROM information_schema.columns + WHERE table_name = ${insightsByPeriodTableName} AND column_name = 'value'`, + ); + expect(result[0].data_type).toBe('bigint'); + } else if (postMigrationContext.isMysql) { + const result = await postMigrationContext.queryRunner.query( + `SHOW COLUMNS FROM ${insightsByPeriodTableName} LIKE 'value'`, + ); + expect(result[0].Type.toLowerCase()).toContain('bigint'); + } + + // Verify data integrity after migration using SQL + const afterData = await postMigrationContext.queryRunner.query( + `SELECT id, metaId, value FROM ${insightsByPeriodTableName} ORDER BY id ASC`, + ); + expect(afterData).toHaveLength(beforeData.length); + + // Verify all values are preserved exactly + afterData.forEach( + (afterRow: { id: number; metaId: number; value: number }, index: number) => { + expect(afterRow.value).toBe(beforeData[index].value); + expect(afterRow.metaId).toBe(beforeData[index].metaId); + }, + ); + + // Cleanup + await postMigrationContext.queryRunner.release(); + }); + }); + + describe('Post-Migration Capacity', () => { + it('should accept values exceeding INT range', async () => { + const context = createTestMigrationContext(dataSource); + + // Get metaId from existing metadata + const metaTableName = context.escape.tableName('insights_metadata'); + const [metaRow] = await context.queryRunner.query( + `SELECT metaId FROM ${metaTableName} LIMIT 1`, + ); + const metaId = metaRow.metaId; + + // Insert value exceeding INT32 max + const beyondIntValue = BOUNDARY_TEST_VALUES.beyondInt; + const insightsByPeriodTableName = context.escape.tableName('insights_by_period'); + await context.queryRunner.query( + `INSERT INTO ${insightsByPeriodTableName} (metaId, type, value, periodUnit, periodStart) VALUES (?, ?, ?, ?, ?)`, + [metaId, 0, beyondIntValue, 0, new Date()], + ); + + // Verify retrieval using SQL + const [result] = await context.queryRunner.query( + `SELECT id, metaId, value FROM ${insightsByPeriodTableName} WHERE value = ?`, + [beyondIntValue], + ); + + expect(result).toBeDefined(); + expect(result.value).toBe(beyondIntValue); + + // Cleanup + await context.queryRunner.release(); + }); + }); +}); diff --git a/packages/cli/src/modules/insights/__tests__/insights-raw-migration.test.ts b/packages/cli/src/modules/insights/__tests__/insights-raw-migration.test.ts new file mode 100644 index 00000000000..257fa2cf1a8 --- /dev/null +++ b/packages/cli/src/modules/insights/__tests__/insights-raw-migration.test.ts @@ -0,0 +1,163 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + testModules, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; + +import { BOUNDARY_TEST_VALUES, insertPreMigrationRawData } from './migration-test-setup'; + +const MIGRATION_NAME = 'ChangeValueTypesForInsights1759399811000'; + +describe('ChangeValueTypesForInsights - insights_raw table', () => { + let dataSource: DataSource; + + beforeAll(async () => { + await testModules.loadModules(['insights']); + + // Initialize DB connection without running migrations + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + + dataSource = Container.get(DataSource); + + // Run migrations up to (but not including) target migration + await initDbUpToMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + describe('Schema Migration', () => { + it('should change value column from INT to BIGINT', async () => { + // Create migration context for schema queries + const context = createTestMigrationContext(dataSource); + + // Create prerequisite data for foreign keys using direct SQL + const projectTableName = context.escape.tableName('project'); + await context.queryRunner.query( + `INSERT INTO ${projectTableName} (id, name, type, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`, + ['test-project-id', 'Test Project', 'personal', new Date(), new Date()], + ); + + const workflowTableName = context.escape.tableName('workflow_entity'); + await context.queryRunner.query( + `INSERT INTO ${workflowTableName} (id, name, active, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`, + ['test-workflow-id', 'Test Workflow', false, new Date(), new Date()], + ); + + // Insert test metadata (required for foreign key) + const metaTableName = context.escape.tableName('insights_metadata'); + await context.queryRunner.query( + `INSERT INTO ${metaTableName} (workflowId, projectId, workflowName, projectName) VALUES (?, ?, ?, ?)`, + ['test-workflow-id', 'test-project-id', 'Test Workflow', 'Test Project'], + ); + const [metaRow] = await context.queryRunner.query( + `SELECT metaId FROM ${metaTableName} LIMIT 1`, + ); + const metaId = metaRow.metaId; + + // Insert test data in old INT schema + const testValues = [ + BOUNDARY_TEST_VALUES.zero, + BOUNDARY_TEST_VALUES.negativeOne, + BOUNDARY_TEST_VALUES.positiveOne, + BOUNDARY_TEST_VALUES.intMax, + BOUNDARY_TEST_VALUES.intMin, + ]; + await insertPreMigrationRawData(context, metaId, testValues); + + // Capture data before migration using SQL + const insightsRawTableName = context.escape.tableName('insights_raw'); + const beforeData = await context.queryRunner.query( + `SELECT id, metaId, value FROM ${insightsRawTableName} ORDER BY id ASC`, + ); + expect(beforeData).toHaveLength(testValues.length); + + // Run the migration + await runSingleMigration(MIGRATION_NAME); + + // Release old query runner before creating new one + await context.queryRunner.release(); + + // Create fresh context after migration (dataSource is reinitialized) + const postMigrationContext = createTestMigrationContext(dataSource); + + // Verify schema change based on database type + if (postMigrationContext.isSqlite) { + const result = await postMigrationContext.queryRunner.query( + `PRAGMA table_info(${insightsRawTableName})`, + ); + const valueColumn = result.find((col: { name: string }) => col.name === 'value'); + expect(valueColumn).toBeDefined(); + expect(valueColumn.type.toLowerCase()).toContain('bigint'); + } else if (postMigrationContext.isPostgres) { + const result = await postMigrationContext.queryRunner.query( + `SELECT data_type FROM information_schema.columns + WHERE table_name = ${insightsRawTableName} AND column_name = 'value'`, + ); + expect(result[0].data_type).toBe('bigint'); + } else if (postMigrationContext.isMysql) { + const result = await postMigrationContext.queryRunner.query( + `SHOW COLUMNS FROM ${insightsRawTableName} LIKE 'value'`, + ); + expect(result[0].Type.toLowerCase()).toContain('bigint'); + } + + // Verify data integrity after migration using SQL + const afterData = await postMigrationContext.queryRunner.query( + `SELECT id, metaId, value FROM ${insightsRawTableName} ORDER BY id ASC`, + ); + expect(afterData).toHaveLength(beforeData.length); + + // Verify all values are preserved exactly + afterData.forEach( + (afterRow: { id: number; metaId: number; value: number }, index: number) => { + expect(afterRow.value).toBe(beforeData[index].value); + expect(afterRow.metaId).toBe(beforeData[index].metaId); + }, + ); + + // Cleanup + await postMigrationContext.queryRunner.release(); + }); + }); + + describe('Post-Migration Capacity', () => { + it('should accept values exceeding INT range', async () => { + const context = createTestMigrationContext(dataSource); + + // Get metaId from existing metadata + const metaTableName = context.escape.tableName('insights_metadata'); + const [metaRow] = await context.queryRunner.query( + `SELECT metaId FROM ${metaTableName} LIMIT 1`, + ); + const metaId = metaRow.metaId; + + // Insert value exceeding INT32 max + const beyondIntValue = BOUNDARY_TEST_VALUES.beyondInt; + const insightsRawTableName = context.escape.tableName('insights_raw'); + await context.queryRunner.query( + `INSERT INTO ${insightsRawTableName} (metaId, type, value, timestamp) VALUES (?, ?, ?, ?)`, + [metaId, 0, beyondIntValue, new Date()], + ); + + // Verify retrieval using SQL + const [result] = await context.queryRunner.query( + `SELECT id, metaId, value FROM ${insightsRawTableName} WHERE value = ?`, + [beyondIntValue], + ); + + expect(result).toBeDefined(); + expect(result.value).toBe(beyondIntValue); + + // Cleanup + await context.queryRunner.release(); + }); + }); +}); diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts index 700e9dc7fa7..f98de78acaa 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts @@ -227,53 +227,54 @@ describe('InsightsService', () => { ]); }); - test('mixed period data are summarized correctly', async () => { + // eslint-disable-next-line n8n-local-rules/no-skipped-tests + test.skip('mixed period data are summarized correctly', async () => { // ARRANGE + const now = DateTime.utc(); + // current period await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 14 }), + periodStart: now.minus({ day: 14 }), }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 2, periodUnit: 'hour', - periodStart: DateTime.utc().minus({ day: 10 }), + periodStart: now.minus({ day: 10 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 11, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 13 }), + periodStart: now.minus({ day: 13 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 8, periodUnit: 'hour', - periodStart: DateTime.utc().minus({ day: 10, hours: 8 }), + periodStart: now.minus({ day: 10, hours: 8 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 1, periodUnit: 'hour', - periodStart: DateTime.utc().minus({ day: 9, hours: 7 }), + periodStart: now.minus({ day: 9, hours: 7 }), }); - await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: 35789, periodUnit: 'week', - periodStart: DateTime.utc().minus({ day: 14 }), + periodStart: now.minus({ day: 14 }), }); - await createCompactedInsightsEvent(workflow, { type: 'time_saved_min', value: 15, periodUnit: 'week', - periodStart: DateTime.utc().minus({ day: 14 }), + periodStart: now.minus({ day: 14 }), }); // previous period @@ -281,25 +282,25 @@ describe('InsightsService', () => { type: 'success', value: 2, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 16 }), + periodStart: now.minus({ day: 16 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 17 }), + periodStart: now.minus({ day: 17 }), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: 123, periodUnit: 'week', - periodStart: DateTime.utc().minus({ day: 21 }), + periodStart: now.minus({ day: 21 }), }); await createCompactedInsightsEvent(workflow, { type: 'time_saved_min', value: 10, periodUnit: 'week', - periodStart: DateTime.utc().minus({ day: 21 }), + periodStart: now.minus({ day: 21 }), }); // out of range data (after selected period) @@ -307,13 +308,13 @@ describe('InsightsService', () => { type: 'success', value: 5, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 6 }), + periodStart: now.minus({ day: 6 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 3, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 4 }), + periodStart: now.minus({ day: 4 }), }); // out of range data (before selected period) @@ -321,33 +322,34 @@ describe('InsightsService', () => { type: 'success', value: 2, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 22 }), + periodStart: now.minus({ day: 22 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ year: 1 }), + periodStart: now.minus({ year: 1 }), }); - const startDate = DateTime.utc().minus({ days: 14 }).toJSDate(); - const endDate = DateTime.utc().minus({ days: 7 }).toJSDate(); + const startDate = now.minus({ days: 14 }).toJSDate(); + const endDate = now.minus({ days: 7 }).toJSDate(); // ACT const summary = await insightsService.getInsightsSummary({ startDate, endDate }); // ASSERT expect(summary).toEqual({ - averageRunTime: { value: 0, unit: 'millisecond', deviation: -7157.8 }, + averageRunTime: { value: 1556.04, unit: 'millisecond', deviation: 1525.29 }, failed: { value: 20, unit: 'count', deviation: 18 }, - failureRate: { value: 0.909, unit: 'ratio', deviation: 0.509 }, - timeSaved: { value: 0, unit: 'minute', deviation: -15 }, - total: { value: 22, unit: 'count', deviation: 17 }, + failureRate: { value: 0.87, unit: 'ratio', deviation: 0.37 }, + timeSaved: { value: 15, unit: 'minute', deviation: 5 }, + total: { value: 23, unit: 'count', deviation: 19 }, }); }); test('filter by projectId', async () => { // ARRANGE + const now = DateTime.utc(); const otherProject = await createTeamProject(); const otherWorkflow = await createWorkflow({}, otherProject); @@ -356,32 +358,32 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 2 }), + periodStart: now.minus({ day: 2 }), }); await createCompactedInsightsEvent(workflow, { type: 'failure', value: 1, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(otherWorkflow, { type: 'runtime_ms', value: 430, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 1 }), + periodStart: now.minus({ day: 1 }), }); await createCompactedInsightsEvent(otherWorkflow, { type: 'failure', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 3 }), + periodStart: now.minus({ day: 3 }), }); // last 12 days @@ -389,40 +391,41 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: 123, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); await createCompactedInsightsEvent(otherWorkflow, { type: 'runtime_ms', value: 45, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 11 }), + periodStart: now.minus({ days: 11 }), }); + //Outside range should not be taken into account await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: 123, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 13 }), + periodStart: now.minus({ days: 13 }), }); await createCompactedInsightsEvent(otherWorkflow, { type: 'runtime_ms', value: 100, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 20 }), + periodStart: now.minus({ days: 20 }), }); - const startDate = DateTime.utc().minus({ days: 6 }).toJSDate(); + const startDate = now.minus({ days: 6 }).toJSDate(); // ACT const summary = await insightsService.getInsightsSummary({ startDate, - endDate: today, + endDate: now.toJSDate(), projectId: project.id, }); @@ -687,7 +690,7 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 13, hours: 23 }), + periodStart: now.minus({ days: 14 }).startOf('day'), }); // Out of date range insight (should not be included) @@ -696,7 +699,7 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 14 }), + periodStart: now.minus({ days: 15 }), }); } @@ -790,14 +793,15 @@ describe('InsightsService', () => { }); test('returns empty array when no insights in the time range exists', async () => { + const now = DateTime.utc(); await createCompactedInsightsEvent(workflow1, { type: 'success', value: 2, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 30 }), + periodStart: now.minus({ days: 30 }), }); - const startDate = DateTime.utc().minus({ days: 14 }).toJSDate(); + const startDate = now.minus({ days: 14 }).toJSDate(); const byTime = await insightsService.getInsightsByTime({ startDate, @@ -843,21 +847,20 @@ describe('InsightsService', () => { }); // Barely in range insight (should be included) - // 1 hour before 14 days ago await createCompactedInsightsEvent(workflow, { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 13, hours: 23 }), + periodStart: now.minus({ days: 14 }).startOf('day'), }); // Out of date range insight (should not be included) - // 14 days ago + // 15 days ago await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 14 }), + periodStart: now.minus({ days: 15 }), }); } @@ -966,47 +969,47 @@ describe('InsightsService', () => { }); test('compacted data are are grouped by time correctly with projectId filter', async () => { + const now = DateTime.utc(); // ARRANGE for (const workflow of [workflow1, workflow2, workflow3]) { await createCompactedInsightsEvent(workflow, { type: 'success', value: workflow === workflow1 ? 1 : 2, periodUnit: 'day', - periodStart: DateTime.utc(), + periodStart: now, }); // Check that hourly data is grouped together with the previous daily data await createCompactedInsightsEvent(workflow, { type: 'failure', value: 2, periodUnit: 'hour', - periodStart: DateTime.utc(), + periodStart: now, }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ day: 2 }), + periodStart: now.minus({ day: 2 }), }); await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); await createCompactedInsightsEvent(workflow, { type: 'runtime_ms', value: workflow === workflow1 ? 10 : 20, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 10 }), + periodStart: now.minus({ days: 10 }), }); // Barely in range insight (should be included) - // 1 hour before 14 days ago await createCompactedInsightsEvent(workflow, { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), + periodStart: now.minus({ days: 14 }).startOf('day'), }); // Out of date range insight (should not be included) @@ -1015,11 +1018,11 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 14 }), + periodStart: now.minus({ days: 15 }), }); } - const startDate = DateTime.utc().minus({ days: 14 }).toJSDate(); + const startDate = now.minus({ days: 14 }).toJSDate(); // ACT const byTime = await insightsService.getInsightsByTime({ @@ -1032,10 +1035,10 @@ describe('InsightsService', () => { expect(byTime).toHaveLength(4); // expect date to be sorted by oldest first - expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 14 }).startOf('day').toISO()); - expect(byTime[1].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO()); - expect(byTime[2].date).toEqual(DateTime.utc().minus({ days: 2 }).startOf('day').toISO()); - expect(byTime[3].date).toEqual(DateTime.utc().startOf('day').toISO()); + expect(byTime[0].date).toEqual(now.minus({ days: 14 }).startOf('day').toISO()); + expect(byTime[1].date).toEqual(now.minus({ days: 10 }).startOf('day').toISO()); + expect(byTime[2].date).toEqual(now.minus({ days: 2 }).startOf('day').toISO()); + expect(byTime[3].date).toEqual(now.startOf('day').toISO()); expect(byTime[0].values).toEqual({ total: 2, @@ -1199,25 +1202,29 @@ describe('InsightsService', () => { licenseStateMock.isInsightsHourlyDataLicensed.mockReturnValue(false); licenseStateMock.getInsightsMaxHistory.mockReturnValue(30); - const today = DateTime.now().startOf('day'); - const startDate = today.minus({ hours: 12 }).toJSDate(); - const endDate = today.toJSDate(); + const startDate = DateTime.now().minus({ days: 3 }).startOf('day'); + const endDate = startDate.plus({ hours: 10 }); - expect(() => insightsService.validateDateFiltersLicense({ startDate, endDate })).toThrowError( - new UserError('Hourly data is not available with your current license'), - ); + expect(() => + insightsService.validateDateFiltersLicense({ + startDate: startDate.toJSDate(), + endDate: endDate.toJSDate(), + }), + ).toThrowError(new UserError('Hourly data is not available with your current license')); }); test('does not throw if granularity is hour and hourly data is licensed', () => { licenseStateMock.isInsightsHourlyDataLicensed.mockReturnValue(true); licenseStateMock.getInsightsMaxHistory.mockReturnValue(30); - const today = DateTime.now().startOf('day'); - const startDate = today.minus({ hours: 12 }).toJSDate(); - const endDate = today.toJSDate(); + const startDate = DateTime.now().minus({ days: 3 }).startOf('day'); + const endDate = startDate.endOf('day'); expect(() => - insightsService.validateDateFiltersLicense({ startDate, endDate }), + insightsService.validateDateFiltersLicense({ + startDate: startDate.toJSDate(), + endDate: endDate.toJSDate(), + }), ).not.toThrow(); }); diff --git a/packages/cli/src/modules/insights/__tests__/migration-test-setup.ts b/packages/cli/src/modules/insights/__tests__/migration-test-setup.ts new file mode 100644 index 00000000000..394d48ead5a --- /dev/null +++ b/packages/cli/src/modules/insights/__tests__/migration-test-setup.ts @@ -0,0 +1,53 @@ +import type { TestMigrationContext } from '@n8n/backend-test-utils'; + +/** + * Test values for BIGINT migration validation. + * Covers INT32 boundaries and BIGINT capabilities. + */ +export const BOUNDARY_TEST_VALUES = { + zero: 0, + positiveOne: 1, + negativeOne: -1, + intMax: 2_147_483_647, // 2^31 - 1 (max signed 32-bit int) + intMin: -2_147_483_648, // -2^31 (min signed 32-bit int) + beyondInt: 2_147_483_648, // For post-migration testing (exceeds INT32) + bigintMax: 9_223_372_036_854_775_807n, // 2^63 - 1 (max signed 64-bit int) +} as const; + +/** + * Insert raw insights data using SQL (compatible with pre-migration INT schema). + * Uses database-specific helpers for table names and escaping. + */ +export async function insertPreMigrationRawData( + context: TestMigrationContext, + metaId: number, + testValues: number[], +): Promise { + const tableName = context.escape.tableName('insights_raw'); + for (const value of testValues) { + await context.queryRunner.query( + `INSERT INTO ${tableName} (metaId, type, value, timestamp) VALUES (?, ?, ?, ?)`, + [metaId, 0, value, new Date()], + ); + } +} + +/** + * Insert insights_by_period data using SQL (compatible with pre-migration INT schema). + * Uses database-specific helpers for table names and escaping. + */ +export async function insertPreMigrationPeriodData( + context: TestMigrationContext, + metaId: number, + testValues: number[], +): Promise { + const tableName = context.escape.tableName('insights_by_period'); + const baseDate = new Date('2025-01-01T00:00:00.000Z'); + for (let i = 0; i < testValues.length; i++) { + const periodStart = new Date(baseDate.getTime() + i * 3600000); // Add 1 hour per iteration + await context.queryRunner.query( + `INSERT INTO ${tableName} (metaId, type, value, periodUnit, periodStart) VALUES (?, ?, ?, ?, ?)`, + [metaId, 0, testValues[i], 0, periodStart.toISOString()], + ); + } +} diff --git a/packages/cli/src/modules/insights/database/entities/insights-by-period.ts b/packages/cli/src/modules/insights/database/entities/insights-by-period.ts index cef6aeefa8c..bf9c870c777 100644 --- a/packages/cli/src/modules/insights/database/entities/insights-by-period.ts +++ b/packages/cli/src/modules/insights/database/entities/insights-by-period.ts @@ -49,6 +49,11 @@ export class InsightsByPeriod extends BaseEntity { this.type_ = TypeToNumber[value]; } + /** + * Stored as BIGINT in database (see migration 1759399811000). + * JavaScript number type has precision limits at ±2^53-1 (9,007,199,254,740,991). + * Values exceeding Number.MAX_SAFE_INTEGER will lose precision. + */ @Column() value: number; diff --git a/packages/cli/src/modules/insights/database/entities/insights-raw.ts b/packages/cli/src/modules/insights/database/entities/insights-raw.ts index 57c3d30736b..34746394a30 100644 --- a/packages/cli/src/modules/insights/database/entities/insights-raw.ts +++ b/packages/cli/src/modules/insights/database/entities/insights-raw.ts @@ -38,6 +38,11 @@ export class InsightsRaw extends BaseEntity { this.type_ = TypeToNumber[value]; } + /** + * Stored as BIGINT in database (see migration 1759399811000). + * JavaScript number type has precision limits at ±2^53-1 (9,007,199,254,740,991). + * Values exceeding Number.MAX_SAFE_INTEGER will lose precision. + */ @Column() value: number; diff --git a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts new file mode 100644 index 00000000000..2e68234192b --- /dev/null +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts @@ -0,0 +1,502 @@ +import type { DatabaseConfig } from '@n8n/config'; +import { DateTime } from 'luxon'; + +import { getDateRangesCommonTableExpressionQuery } from '../insights-by-period-query.helper'; + +describe('getDateRangesCommonTableExpressionQuery', () => { + const now = DateTime.utc(2025, 10, 8, 8, 51, 27); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(now.toJSDate()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe.each([ + ['sqlite', 'SQLite'], + ['postgresdb', 'PostgreSQL'], + ['mysqldb', 'MySQL'], + ['mariadb', 'MariaDB'], + ])('%s', (dbType: DatabaseConfig['type']) => { + describe('hour periodicity (1 day - startDate == endDate)', () => { + test('last 24 hours (endDate is today)', () => { + const startDate = now.startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-2 days')"); // prev_start_date + expect(result).toContain("datetime('now', '-1 days')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("NOW() - INTERVAL '2 days'"); // prev_start_date + expect(result).toContain("NOW() - INTERVAL '1 days'"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY)'); // prev_start_date + expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY)'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('yesterday (specific day)', () => { + const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-1 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '1 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 1 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('7 days ago (specific day)', () => { + const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-8 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-7 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-6 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '8 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '7 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '6 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 8 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 7 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 6 DAY))'); // end_date + } + }); + + test('14 days ago (specific day)', () => { + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-15 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-14 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-13 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '15 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '14 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '13 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 15 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 14 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 13 DAY))'); // end_date + } + }); + + test('X days ago (specific day far in the past)', () => { + // 109 days ago (2025-06-21) + const startDate = now.minus({ days: 109 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 109 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-110 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-109 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-108 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '110 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '109 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '108 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 110 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 109 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 108 DAY))'); // end_date + } + }); + }); + + describe('day periodicity (2-30 days)', () => { + test('last 7 days (endDate is today)', () => { + const startDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-12 days', 'start of day')"); // prev_start_date (6 + 6) + expect(result).toContain("datetime('now', '-6 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '12 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '6 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 12 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 6 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last 14 days (endDate is today)', () => { + const startDate = now.minus({ days: 13 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-26 days', 'start of day')"); // prev_start_date (13 + 13) + expect(result).toContain("datetime('now', '-13 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '26 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '13 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 26 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 13 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last 30 days (endDate is today)', () => { + const startDate = now.minus({ days: 29 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-58 days', 'start of day')"); // prev_start_date (29 + 29) + expect(result).toContain("datetime('now', '-29 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '58 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '29 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 58 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 29 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('2 days range (specific historical range)', () => { + const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-3 days', 'start of day')"); // prev_start_date (2 + 1) + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '3 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 3 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('5 days range (specific historical range)', () => { + const startDate = now.minus({ days: 10 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-14 days', 'start of day')"); // prev_start_date (10 + 4) + expect(result).toContain("datetime('now', '-10 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-5 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '14 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '10 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '5 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 14 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 10 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 5 DAY))'); // end_date + } + }); + + test('7 days range (specific historical range)', () => { + const startDate = now.minus({ days: 12 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-18 days', 'start of day')"); // prev_start_date (12 + 6) + expect(result).toContain("datetime('now', '-12 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-5 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '18 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '12 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '5 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 18 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 12 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 5 DAY))'); // end_date + } + }); + + test('14 days range (specific historical range)', () => { + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-27 days', 'start of day')"); // prev_start_date (14 + 13) + expect(result).toContain("datetime('now', '-14 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '27 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '14 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 27 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 14 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('30 days range (specific historical range)', () => { + const startDate = now.minus({ days: 30 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-59 days', 'start of day')"); // prev_start_date (30 + 29) + expect(result).toContain("datetime('now', '-30 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '59 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '30 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 59 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 30 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + }); + + describe('week periodicity (31+ days)', () => { + test('last 90 days (endDate is today)', () => { + const startDate = now.minus({ days: 89 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-178 days', 'start of day')"); // prev_start_date (89 + 89) + expect(result).toContain("datetime('now', '-89 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '178 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '89 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 178 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 89 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last 6 months (endDate is today)', () => { + const startDate = now.minus({ months: 6 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); + const prevDaysBack = daysBack * 2; + + if (dbType === 'sqlite') { + expect(result).toContain(`datetime('now', '-${prevDaysBack} days', 'start of day')`); // prev_start_date + expect(result).toContain(`datetime('now', '-${daysBack} days', 'start of day')`); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${prevDaysBack} days')`); // prev_start_date + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${daysBack} days')`); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prevDaysBack} DAY))`); // prev_start_date + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${daysBack} DAY))`); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last year (endDate is today)', () => { + const startDate = now.minus({ years: 1 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); + const prevDaysBack = daysBack * 2; + + if (dbType === 'sqlite') { + expect(result).toContain(`datetime('now', '-${prevDaysBack} days', 'start of day')`); // prev_start_date + expect(result).toContain(`datetime('now', '-${daysBack} days', 'start of day')`); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${prevDaysBack} days')`); // prev_start_date + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${daysBack} days')`); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prevDaysBack} DAY))`); // prev_start_date + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${daysBack} DAY))`); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('31 days range (specific historical range)', () => { + const startDate = now.minus({ days: 31 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-61 days', 'start of day')"); // prev_start_date (31 + 30) + expect(result).toContain("datetime('now', '-31 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '61 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '31 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 61 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 31 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('90 days range (specific historical range)', () => { + const startDate = now.minus({ days: 90 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-179 days', 'start of day')"); // prev_start_date (90 + 89) + expect(result).toContain("datetime('now', '-90 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '179 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '90 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 179 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 90 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('180 days range (specific historical range)', () => { + const startDate = now.minus({ days: 180 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-359 days', 'start of day')"); // prev_start_date (180 + 179) + expect(result).toContain("datetime('now', '-180 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '359 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '180 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 359 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 180 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('360 days range (specific historical range)', () => { + const startDate = now.minus({ days: 360 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-719 days', 'start of day')"); // prev_start_date (360 + 359) + expect(result).toContain("datetime('now', '-360 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '719 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '360 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 719 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 360 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + }); + + describe('edge cases', () => { + test('handles date with time component correctly', () => { + // Date with time should be treated as start of day + const startDate = DateTime.utc(2025, 10, 6, 14, 30, 0).toJSDate(); + const endDate = DateTime.utc(2025, 10, 7, 18, 45, 30).toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + // Should calculate based on start of day values (2-day range) + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-3 days', 'start of day')"); // prev_start_date (2 + 1) + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '3 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 3 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('handles same day with different times correctly (hour periodicity)', () => { + const startDate = DateTime.utc(2025, 10, 7, 9, 0, 0).toJSDate(); + const endDate = DateTime.utc(2025, 10, 7, 17, 0, 0).toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + // Should treat as single day (yesterday) - hour periodicity + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // prev_start_date (1 + 1) + expect(result).toContain("datetime('now', '-1 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '1 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 1 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + }); + }); +}); diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts new file mode 100644 index 00000000000..a6ca190c577 --- /dev/null +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts @@ -0,0 +1,133 @@ +import type { DatabaseConfig } from '@n8n/config'; +import { sql } from '@n8n/db'; +import { DateTime } from 'luxon'; + +/** + * Generates database-specific SQL for a datetime value relative to now + * @param dbType - The database type + * @param daysFromToday - Number of days back from today (0 = now) + * @param useStartOfDay - Whether to truncate to start of day (00:00:00) + */ +const getDatetimeSql = ({ + dbType, + daysFromToday, + useStartOfDay = false, +}: { + dbType: DatabaseConfig['type']; + daysFromToday: number; + useStartOfDay?: boolean; +}): string => { + // Handle "now" case + if (daysFromToday === 0 && !useStartOfDay) { + return dbType === 'sqlite' ? "datetime('now')" : 'NOW()'; + } + + // SQLite + if (dbType === 'sqlite') { + if (daysFromToday === 0 && useStartOfDay) { + return "datetime('now', 'start of day')"; + } + if (useStartOfDay) { + return `datetime('now', '-${daysFromToday} days', 'start of day')`; + } + return `datetime('now', '-${daysFromToday} days')`; + } + + // PostgreSQL + if (dbType === 'postgresdb') { + if (daysFromToday === 0 && useStartOfDay) { + return "DATE_TRUNC('day', NOW())"; + } + if (useStartOfDay) { + return `DATE_TRUNC('day', NOW() - INTERVAL '${daysFromToday} days')`; + } + return `NOW() - INTERVAL '${daysFromToday} days'`; + } + + // MySQL/MariaDB + if (daysFromToday === 0 && useStartOfDay) { + return 'DATE(NOW())'; + } + if (useStartOfDay) { + return `DATE(DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY))`; + } + return `DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY)`; +}; + +/** + * Generates a SQL Common Table Expression (CTE) query that provides three date boundaries for insights queries + * + * Behavior: + * - If startDate and endDate are the same and today + * - returns the last 24 hours: prev_start_date (2 days ago), start_date (1 day ago), end_date (now). + * - Otherwise: + * - prev_start_date: start of the day before the range + * - start_date: start of the current range + * - end_date: "now" if endDate is today, else start of the day after endDate + * + * The SQL CTE can be joined with the insights table for filtering/aggregation. + * + * @param dbType - The database type ('sqlite', 'postgresdb', 'mysqldb', 'mariadb') + * @param startDate - The start date of the range (inclusive) + * @param endDate - The end date of the range (inclusive, or "now" if today) + * @returns SQL CTE query with `prev_start_date`, `start_date`, and `end_date` columns + * - `prev_start_date`: The start of the previous period (used for comparison) + * - `start_date`: The start of the current period (inclusive) + * - `end_date`: The end of the current period (exclusive) + */ +export const getDateRangesCommonTableExpressionQuery = ({ + dbType, + startDate, + endDate, +}: { + dbType: DatabaseConfig['type']; + startDate: Date; + endDate: Date; +}) => { + const today = DateTime.now().startOf('day'); + const startDateStartOfDay = DateTime.fromJSDate(startDate).startOf('day'); + const endDateStartOfDay = DateTime.fromJSDate(endDate).startOf('day'); + + const daysFromEndDateToToday = Math.floor(today.diff(endDateStartOfDay, 'days').days); + const daysDiff = Math.floor(endDateStartOfDay.diff(startDateStartOfDay, 'days').days); + + const isEndDateToday = daysFromEndDateToToday === 0; + + let prevStartDateSql: string; + let startDateSql: string; + let endDateSql: string; + + if (daysDiff === 0 && isEndDateToday) { + // Last 24 hours + prevStartDateSql = getDatetimeSql({ dbType, daysFromToday: 2, useStartOfDay: false }); + startDateSql = getDatetimeSql({ dbType, daysFromToday: 1, useStartOfDay: false }); + endDateSql = getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }); + } else { + // Calculate the date range (minimum 1 day) for previous period + const dateRangeInDays = Math.max(1, daysDiff); + const daysFromStartDateToToday = Math.floor(today.diff(startDateStartOfDay, 'days').days); + const prevStartDaysFromToday = daysFromStartDateToToday + dateRangeInDays; + + prevStartDateSql = getDatetimeSql({ + dbType, + daysFromToday: prevStartDaysFromToday, + useStartOfDay: true, + }); + + startDateSql = getDatetimeSql({ + dbType, + daysFromToday: daysFromStartDateToToday, + useStartOfDay: true, + }); + + endDateSql = isEndDateToday + ? getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }) + : getDatetimeSql({ dbType, daysFromToday: daysFromEndDateToToday - 1, useStartOfDay: true }); + } + + return sql`SELECT + ${prevStartDateSql} AS prev_start_date, + ${startDateSql} AS start_date, + ${endDateSql} AS end_date + `; +}; diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts index 2eb583433a3..2a00b358a7f 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts @@ -6,6 +6,7 @@ import { DataSource, LessThanOrEqual, Repository } from '@n8n/typeorm'; import { DateTime } from 'luxon'; import { z } from 'zod'; +import { getDateRangesCommonTableExpressionQuery } from './insights-by-period-query.helper'; import { InsightsByPeriod } from '../entities/insights-by-period'; import type { PeriodUnit, TypeUnit } from '../entities/insights-shared'; import { PeriodUnitToNumber, TypeToNumber } from '../entities/insights-shared'; @@ -140,7 +141,7 @@ export class InsightsByPeriodRepository extends Repository { }>; } - getAggregationQuery(periodUnit: PeriodUnit) { + private getAggregationQuery(periodUnit: PeriodUnit) { // Get the start period expression depending on the period unit and database type const periodStartExpr = this.getPeriodStartExpr(periodUnit); @@ -268,18 +269,6 @@ export class InsightsByPeriodRepository extends Repository { } } - private getAgeLimitQuery(maxAgeInDays: number) { - if (maxAgeInDays === 0) { - return dbType === 'sqlite' ? "datetime('now')" : 'NOW()'; - } - - return dbType === 'sqlite' - ? `datetime('now', '-${maxAgeInDays} days')` - : dbType === 'postgresdb' - ? `NOW() - INTERVAL '${maxAgeInDays} days'` - : `DATE_SUB(NOW(), INTERVAL ${maxAgeInDays} DAY)`; - } - async getPreviousAndCurrentPeriodTypeAggregates({ startDate, endDate, @@ -291,25 +280,14 @@ export class InsightsByPeriodRepository extends Repository { total_value: string | number; }> > { - const { daysFromStartDateToToday, daysFromEndDateToToday, dateRangeInDays } = - this.getDateRangesDaysLimits({ - startDate, - endDate, - }); - - const cte = sql` - SELECT - ${this.getAgeLimitQuery(daysFromStartDateToToday)} AS current_start, - ${this.getAgeLimitQuery(daysFromEndDateToToday)} AS current_end, - ${this.getAgeLimitQuery(daysFromStartDateToToday + dateRangeInDays)} AS previous_start - `; + const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const rawRowsQuery = this.createQueryBuilder('insights') .addCommonTableExpression(cte, 'date_ranges') .select( sql` CASE - WHEN insights.periodStart >= date_ranges.current_start AND insights.periodStart <= date_ranges.current_end + WHEN insights.periodStart >= date_ranges.start_date AND insights.periodStart < date_ranges.end_date THEN 'current' ELSE 'previous' END @@ -320,8 +298,8 @@ export class InsightsByPeriodRepository extends Repository { .addSelect('SUM(value)', 'total_value') // Use a cross join with the CTE .innerJoin('date_ranges', 'date_ranges', '1=1') - .where('insights.periodStart >= date_ranges.previous_start') - .andWhere('insights.periodStart <= date_ranges.current_end') + .where('insights.periodStart >= date_ranges.prev_start_date') + .andWhere('insights.periodStart < date_ranges.end_date') // Group by both period and type .groupBy('period') .addGroupBy('insights.type'); @@ -360,16 +338,7 @@ export class InsightsByPeriodRepository extends Repository { const [sortField, sortOrder] = this.parseSortingParams(sortBy); const sumOfExecutions = sql`SUM(CASE WHEN insights.type IN (${TypeToNumber.success.toString()}, ${TypeToNumber.failure.toString()}) THEN value ELSE 0 END)`; - const { daysFromStartDateToToday, daysFromEndDateToToday } = this.getDateRangesDaysLimits({ - startDate, - endDate, - }); - - const cte = sql` - SELECT - ${this.getAgeLimitQuery(daysFromStartDateToToday)} AS start_date, - ${this.getAgeLimitQuery(daysFromEndDateToToday)} AS end_date - `; + const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const rawRowsQuery = this.createQueryBuilder('insights') .addCommonTableExpression(cte, 'date_ranges') @@ -396,7 +365,7 @@ export class InsightsByPeriodRepository extends Repository { // Use a cross join with the CTE .innerJoin('date_ranges', 'date_ranges', '1=1') .where('insights.periodStart >= date_ranges.start_date') - .andWhere('insights.periodStart <= date_ranges.end_date') + .andWhere('insights.periodStart < date_ranges.end_date') .groupBy('metadata.workflowId') .addGroupBy('metadata.workflowName') .addGroupBy('metadata.projectId') @@ -426,16 +395,7 @@ export class InsightsByPeriodRepository extends Repository { startDate: Date; endDate: Date; }) { - const { daysFromStartDateToToday, daysFromEndDateToToday } = this.getDateRangesDaysLimits({ - startDate, - endDate, - }); - - const cte = sql` - SELECT - ${this.getAgeLimitQuery(daysFromStartDateToToday)} AS start_date, - ${this.getAgeLimitQuery(daysFromEndDateToToday)} AS end_date - `; + const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const typesAggregation = insightTypes.map((type) => { return `SUM(CASE WHEN insights.type = ${TypeToNumber[type]} THEN value ELSE 0 END) AS "${displayTypeName[TypeToNumber[type]]}"`; @@ -461,27 +421,6 @@ export class InsightsByPeriodRepository extends Repository { return aggregatedInsightsByTimeParser.parse(rawRows); } - private getDateRangesDaysLimits({ startDate, endDate }: { startDate: Date; endDate: Date }) { - const today = DateTime.now().startOf('day'); - const startDateStartOfDay = DateTime.fromJSDate(startDate).startOf('day'); - const endDateStartOfDay = DateTime.fromJSDate(endDate).startOf('day'); - - let daysFromStartDateToToday = today.diff(startDateStartOfDay, 'days').days; - // ensure that at least one day is covered - if (daysFromStartDateToToday < 1) { - daysFromStartDateToToday = 1; - } - const daysFromEndDateToToday = today.diff(endDateStartOfDay, 'days').days; - - const dateRangeInDays = daysFromStartDateToToday - daysFromEndDateToToday; - - return { - daysFromStartDateToToday, - daysFromEndDateToToday, - dateRangeInDays, - }; - } - async pruneOldData(maxAgeInDays: number): Promise<{ affected: number | null | undefined }> { const thresholdDate = DateTime.now().minus({ days: maxAgeInDays }).startOf('day').toJSDate(); const result = await this.delete({ diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index 69dbe356b25..d23d46ba1cd 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -161,7 +161,10 @@ export class InsightsController { if (query.dateRange) { const maxAgeInDays = keyRangeToDays[query.dateRange]; return { - startDate: DateTime.now().minus({ days: maxAgeInDays }).toJSDate(), + startDate: + maxAgeInDays === 1 + ? DateTime.now().startOf('day').toJSDate() + : DateTime.now().minus({ days: maxAgeInDays }).toJSDate(), endDate: today, }; } diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 7c45f4a8cc7..959f87a4372 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -275,7 +275,7 @@ export class InsightsService { const endDateTime = DateTime.fromJSDate(endDate); const differenceInDays = endDateTime.diff(startDateTime, 'days').days; - if (differenceInDays <= 1) { + if (differenceInDays < 1) { return 'hour'; } diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts index bd3572af668..aed2a0a8a48 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts @@ -239,6 +239,25 @@ describe('McpSettingsController', () => { expect(workflowService.update).not.toHaveBeenCalled(); }); + test('allows disabling MCP for inactive workflows', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue( + createWorkflow({ active: false }), + ); + workflowService.update.mockResolvedValue({ + id: workflowId, + settings: { saveManualExecutions: true, availableInMCP: false }, + versionId: 'client-version', + } as unknown as WorkflowEntity); + + const req = createReq({}, { user }); + + await controller.toggleWorkflowMCPAccess(req, mock(), workflowId, { + availableInMCP: false, + }); + + expect(workflowService.update).toHaveBeenCalledTimes(1); + }); + test('rejects enabling MCP without active webhook nodes', async () => { workflowFinderService.findWorkflowForUser.mockResolvedValue( createWorkflow({ @@ -296,26 +315,5 @@ describe('McpSettingsController', () => { versionId: 'updated-version-id', }); }); - - test('rejects disabling MCP for inactive workflows', async () => { - workflowFinderService.findWorkflowForUser.mockResolvedValue( - createWorkflow({ active: false }), - ); - workflowService.update.mockResolvedValue({ - id: workflowId, - settings: { saveManualExecutions: true, availableInMCP: false }, - versionId: 'client-version', - } as unknown as WorkflowEntity); - - const req = createReq({}, { user }); - - await expect( - controller.toggleWorkflowMCPAccess(req, mock(), workflowId, { - availableInMCP: false, - }), - ).rejects.toThrow(new BadRequestError('MCP access can only be set for active workflows')); - - expect(workflowService.update).not.toHaveBeenCalled(); - }); }); }); diff --git a/packages/cli/src/modules/mcp/mcp-api-key.service.ts b/packages/cli/src/modules/mcp/mcp-api-key.service.ts index 123e4f473b7..b3001b4ae69 100644 --- a/packages/cli/src/modules/mcp/mcp-api-key.service.ts +++ b/packages/cli/src/modules/mcp/mcp-api-key.service.ts @@ -44,7 +44,6 @@ export class McpServerApiKeyService { label: API_KEY_LABEL, }); - // @ts-ignore Workaround for intermittent typecheck issue with _QueryDeepPartialEntity await manager.insert(ApiKey, apiKeyEntity); return await manager.findOneByOrFail(ApiKey, { apiKey }); diff --git a/packages/cli/src/modules/mcp/mcp.event-relay.ts b/packages/cli/src/modules/mcp/mcp.event-relay.ts new file mode 100644 index 00000000000..68d40d38e1d --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.event-relay.ts @@ -0,0 +1,60 @@ +import { Logger } from '@n8n/backend-common'; +import { WorkflowRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { EventService } from '@/events/event.service'; +import { EventRelay } from '@/events/relays/event-relay'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; + +/** + * Event relay for MCP module to handle workflow events + */ +@Service() +export class McpEventRelay extends EventRelay { + constructor( + eventService: EventService, + private readonly workflowRepository: WorkflowRepository, + private readonly logger: Logger, + ) { + super(eventService); + } + + init() { + this.setupListeners({ + 'workflow-deactivated': async (event) => await this.onWorkflowDeactivated(event), + }); + } + + /** + * Handles workflow deactivated events. + * When a workflow is deactivated, automatically disables MCP access. + */ + private async onWorkflowDeactivated(event: RelayEventMap['workflow-deactivated']) { + const { workflow, workflowId } = event; + + // Only process if workflow has MCP access enabled + if (workflow.settings?.availableInMCP === true) { + try { + // Update the workflow settings to disable MCP access + const updatedSettings = { + ...workflow.settings, + availableInMCP: false, + }; + + await this.workflowRepository.update(workflowId, { + settings: updatedSettings, + }); + + this.logger.info('Disabled MCP access for deactivated workflow', { + workflowId, + workflowName: workflow.name, + }); + } catch (error) { + this.logger.error('Failed to disable MCP access for deactivated workflow', { + workflowId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } +} diff --git a/packages/cli/src/modules/mcp/mcp.module.ts b/packages/cli/src/modules/mcp/mcp.module.ts index b20427a24e3..a7e19112572 100644 --- a/packages/cli/src/modules/mcp/mcp.module.ts +++ b/packages/cli/src/modules/mcp/mcp.module.ts @@ -12,6 +12,10 @@ export class McpModule implements ModuleInterface { async init() { await import('./mcp.controller'); await import('./mcp.settings.controller'); + + // Initialize event relay to handle workflow deactivation + const { McpEventRelay } = await import('./mcp.event-relay'); + Container.get(McpEventRelay).init(); } /** diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index bfa61c212aa..e5677a1727e 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -78,7 +78,7 @@ export class McpSettingsController { ); } - if (!workflow.active) { + if (!workflow.active && dto.availableInMCP) { throw new BadRequestError('MCP access can only be set for active workflows'); } diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 02ef3c8825a..cc9fbbf9b0a 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -368,6 +368,13 @@ export = { workflow.active = true; + Container.get(EventService).emit('workflow-activated', { + user: req.user, + workflowId: workflow.id, + workflow, + publicApi: true, + }); + return res.json(workflow); } @@ -402,6 +409,13 @@ export = { workflow.active = false; + Container.get(EventService).emit('workflow-deactivated', { + user: req.user, + workflowId: workflow.id, + workflow, + publicApi: true, + }); + return res.json(workflow); } diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts index bc562d5ceb1..1d530de4e43 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts @@ -104,7 +104,6 @@ export async function deleteWorkflow(workflow: WorkflowEntity): Promise { }), }); const roleService = mock(); + const mailer = mock(); const userService = new UserService( mock(), userRepository, - mock(), + mailer, urlService, mock(), mock(), roleService, + globalConfig, ); const commonMockUser = Object.assign(new User(), { @@ -45,6 +48,10 @@ describe('UserService', () => { role: GLOBAL_MEMBER_ROLE, }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('toPublic', () => { it('should remove sensitive properties', async () => { const mockUser = Object.assign(new User(), { @@ -103,6 +110,157 @@ describe('UserService', () => { }); }); + describe('inviteUrl visibility', () => { + describe('when inviteLinksEmailOnly = false', () => { + beforeEach(() => { + globalConfig.userManagement.inviteLinksEmailOnly = false; + }); + + describe('toPublic', () => { + it('should include inviteAcceptUrl if requested', async () => { + const inviter = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const pendingUser = Object.assign(new User(), { + id: uuid(), + role: GLOBAL_MEMBER_ROLE, + isPending: true, + }); + + const result = await userService.toPublic(pendingUser, { + withInviteUrl: true, + inviterId: inviter.id, + }); + + expect(result.inviteAcceptUrl).toBeDefined(); + const url = new URL(result.inviteAcceptUrl ?? ''); + expect(url.searchParams.get('inviterId')).toBe(inviter.id); + expect(url.searchParams.get('inviteeId')).toBe(pendingUser.id); + }); + + it('should not include inviteAcceptUrl if not requested', async () => { + const inviter = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const pendingUser = Object.assign(new User(), { + id: uuid(), + role: GLOBAL_MEMBER_ROLE, + isPending: true, + }); + + const result = await userService.toPublic(pendingUser, { + inviterId: inviter.id, + }); + + expect(result.inviteAcceptUrl).toBeUndefined(); + }); + }); + + describe('inviteUsers', () => { + it('should include inviteAcceptUrl if email was not sent', async () => { + const owner = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const invitations = [{ email: 'test@example.com', role: GLOBAL_MEMBER_ROLE.slug }]; + + roleService.checkRolesExist.mockResolvedValue(); + userRepository.findManyByEmail.mockResolvedValue([]); + userRepository.createUserWithProject.mockImplementation(async (userData) => { + return { user: { ...userData, id: uuid() } as User, project: mock() }; + }); + mailer.invite.mockResolvedValue({ emailSent: false }); + + const result = await userService.inviteUsers(owner, invitations); + + expect(result.usersInvited[0].user.inviteAcceptUrl).toBeDefined(); + }); + + it('should not include inviteAcceptUrl if email was sent', async () => { + const owner = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const invitations = [{ email: 'test@example.com', role: GLOBAL_MEMBER_ROLE.slug }]; + + roleService.checkRolesExist.mockResolvedValue(); + userRepository.findManyByEmail.mockResolvedValue([]); + userRepository.createUserWithProject.mockImplementation(async (userData) => { + return { user: { ...userData, id: uuid() } as User, project: mock() }; + }); + mailer.invite.mockResolvedValue({ emailSent: true }); + + const result = await userService.inviteUsers(owner, invitations); + + expect(result.usersInvited[0].user.inviteAcceptUrl).toBeUndefined(); + }); + }); + }); + + describe('when inviteLinksEmailOnly = true', () => { + beforeEach(() => { + globalConfig.userManagement.inviteLinksEmailOnly = true; + }); + + describe('toPublic', () => { + it('should not include inviteAcceptUrl if requested', async () => { + const inviter = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const pendingUser = Object.assign(new User(), { + id: uuid(), + role: GLOBAL_MEMBER_ROLE, + isPending: true, + }); + + const result = await userService.toPublic(pendingUser, { + withInviteUrl: true, + inviterId: inviter.id, + }); + + expect(result.inviteAcceptUrl).toBeUndefined(); + }); + + it('should not include inviteAcceptUrl if not requested', async () => { + const inviter = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const pendingUser = Object.assign(new User(), { + id: uuid(), + role: GLOBAL_MEMBER_ROLE, + isPending: true, + }); + + const result = await userService.toPublic(pendingUser, { + inviterId: inviter.id, + }); + + expect(result.inviteAcceptUrl).toBeUndefined(); + }); + }); + + describe('inviteUsers', () => { + it('should not include inviteAcceptUrl if email was not sent', async () => { + const owner = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const invitations = [{ email: 'test@example.com', role: GLOBAL_MEMBER_ROLE.slug }]; + + roleService.checkRolesExist.mockResolvedValue(); + userRepository.findManyByEmail.mockResolvedValue([]); + userRepository.createUserWithProject.mockImplementation(async (userData) => { + return { user: { ...userData, id: uuid() } as User, project: mock() }; + }); + mailer.invite.mockResolvedValue({ emailSent: false }); + + const result = await userService.inviteUsers(owner, invitations); + + expect(result.usersInvited[0].user.inviteAcceptUrl).toBeUndefined(); + }); + + it('should not include inviteAcceptUrl if email was sent', async () => { + const owner = Object.assign(new User(), { id: uuid(), role: GLOBAL_ADMIN_ROLE }); + const invitations = [{ email: 'test@example.com', role: GLOBAL_MEMBER_ROLE.slug }]; + + roleService.checkRolesExist.mockResolvedValue(); + userRepository.findManyByEmail.mockResolvedValue([]); + userRepository.createUserWithProject.mockImplementation(async (userData) => { + return { user: { ...userData, id: uuid() } as User, project: mock() }; + }); + mailer.invite.mockResolvedValue({ emailSent: true }); + + const result = await userService.inviteUsers(owner, invitations); + + expect(result.usersInvited[0].user.inviteAcceptUrl).toBeUndefined(); + }); + }); + }); + }); + describe('update', () => { // We need to use `save` so that that the subscriber in // packages/@n8n/db/src/entities/Project.ts receives the full user. diff --git a/packages/cli/src/services/ai-workflow-builder.service.ts b/packages/cli/src/services/ai-workflow-builder.service.ts index ebcb583f3e0..ea132d3204f 100644 --- a/packages/cli/src/services/ai-workflow-builder.service.ts +++ b/packages/cli/src/services/ai-workflow-builder.service.ts @@ -83,6 +83,13 @@ export class WorkflowBuilderService { return sessions; } + async getSessionsMetadata(workflowId: string | undefined, user: IUser) { + const service = await this.getService(); + const sessions = await service.getSessions(workflowId, user); + const hasMessages = sessions.sessions.length > 0 && sessions.sessions[0].messages.length > 0; + return { hasMessages }; + } + async getBuilderInstanceCredits(user: IUser) { const service = await this.getService(); return await service.getBuilderInstanceCredits(user); diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 3df03dd1bad..010c8d18341 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -90,7 +90,6 @@ export class ImportService { const exists = workflow.id ? await tx.existsBy(WorkflowEntity, { id: workflow.id }) : false; - // @ts-ignore CAT-957 const upsertResult = await tx.upsert(WorkflowEntity, workflow, ['id']); const workflowId = upsertResult.identifiers.at(0)?.id as string; diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index c3fbe6bbb06..ec2f17f9bbc 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -509,7 +509,6 @@ export class ProjectService { ) { await em.insert( ProjectRelation, - // @ts-ignore CAT-957 relations.map((v) => this.projectRelationRepository.create({ projectId: project.id, diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index b4e51ead929..f67badd2458 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -41,7 +41,6 @@ export class PublicApiKeyService { ) { const apiKey = this.generateApiKey(user, expiresAt); await this.apiKeyRepository.insert( - // @ts-ignore CAT-957 this.apiKeyRepository.create({ userId: user.id, apiKey, diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index 0d4f49856cd..f634259a43c 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -35,6 +35,7 @@ import { UnexpectedError, UserError } from 'n8n-workflow'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { RoleCacheService } from './role-cache.service'; +import { isUniqueConstraintError } from '@/response-helper'; @Service() export class RoleService { @@ -143,6 +144,11 @@ export class RoleService { if (error instanceof UserError && error.message === 'Cannot update system roles') { throw new BadRequestError('Cannot update system roles'); } + + if (error instanceof Error && isUniqueConstraintError(error)) { + throw new BadRequestError(`A role with the name "${displayName}" already exists`); + } + throw error; } } @@ -159,12 +165,20 @@ export class RoleService { role.systemRole = false; role.roleType = newRole.roleType; role.slug = `${newRole.roleType}:${newRole.displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).substring(2, 8)}`; - const createdRole = await this.roleRepository.save(role); - // Invalidate cache after role creation - await this.roleCacheService.invalidateCache(); + try { + const createdRole = await this.roleRepository.save(role); - return this.dbRoleToRoleDTO(createdRole); + // Invalidate cache after role creation + await this.roleCacheService.invalidateCache(); + + return this.dbRoleToRoleDTO(createdRole); + } catch (error) { + if (error instanceof Error && isUniqueConstraintError(error)) { + throw new BadRequestError(`A role with the name "${newRole.displayName}" already exists`); + } + throw error; + } } async checkRolesExist( diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 2fb06a33095..bab3d284c3f 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -17,6 +17,7 @@ import { UserManagementMailer } from '@/user-management/email'; import { PublicApiKeyService } from './public-api-key.service'; import { RoleService } from './role.service'; +import { GlobalConfig } from '@n8n/config'; @Service() export class UserService { @@ -28,6 +29,7 @@ export class UserService { private readonly eventService: EventService, private readonly publicApiKeyService: PublicApiKeyService, private readonly roleService: RoleService, + private readonly globalConfig: GlobalConfig, ) {} async update(userId: string, data: Partial) { @@ -82,7 +84,14 @@ export class UserService { throw new UnexpectedError('Inviter ID is required to generate invite URL'); } - if (options?.withInviteUrl && options?.inviterId && publicUser.isPending) { + const inviteLinksEmailOnly = this.globalConfig.userManagement.inviteLinksEmailOnly; + + if ( + !inviteLinksEmailOnly && + options?.withInviteUrl && + options?.inviterId && + publicUser.isPending + ) { publicUser = this.addInviteUrl(options.inviterId, publicUser); } @@ -135,6 +144,8 @@ export class UserService { ) { const domain = this.urlService.getInstanceBaseUrl(); + const inviteLinksEmailOnly = this.globalConfig.userManagement.inviteLinksEmailOnly; + return await Promise.all( Object.entries(toInviteUsers).map(async ([email, id]) => { const inviteAcceptUrl = `${domain}/signup?inviterId=${owner.id}&inviteeId=${id}`; @@ -142,7 +153,6 @@ export class UserService { user: { id, email, - inviteAcceptUrl, emailSent: false, role, }, @@ -156,7 +166,6 @@ export class UserService { }); if (result.emailSent) { invitedUser.user.emailSent = true; - delete invitedUser.user?.inviteAcceptUrl; this.eventService.emit('user-transactional-email-sent', { userId: id, @@ -165,6 +174,13 @@ export class UserService { }); } + // Only include the invite URL in the response if + // the users configuration allows it + // and the email was not sent (to allow manual copy-paste) + if (!inviteLinksEmailOnly && !result.emailSent) { + invitedUser.user.inviteAcceptUrl = inviteAcceptUrl; + } + this.eventService.emit('user-invited', { user: owner, targetUserId: Object.values(toInviteUsers), diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts index e4b0abb58c8..1cefba9ef07 100644 --- a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -176,10 +176,19 @@ export class OidcService { const prompt = this.oidcConfig.prompt; + const provisioning = this.globalConfig.sso.provisioning; + const provisioningEnabled = + provisioning.scopesProvisionInstanceRole || provisioning.scopesProvisionProjectRoles; + + // Include the custom n8n scope if provisioning is enabled + const scope = provisioningEnabled + ? `openid email profile ${provisioning.scopesName}` + : 'openid email profile'; + const authorizationURL = client.buildAuthorizationUrl(configuration, { redirect_uri: this.getCallbackUrl(), response_type: 'code', - scope: 'openid email profile', + scope, prompt, state: state.plaintext, nonce: nonce.plaintext, diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 3965f9c3049..1c9114bad6c 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -341,7 +341,6 @@ export class EnterpriseWorkflowService { await this.shareCredentialsWithProject(user, shareCredentials, destinationProject.id); // 9. Move workflow to the right folder if any - // @ts-ignore CAT-957 await this.workflowRepository.update({ id: workflow.id }, { parentFolder }); // 10. Update potential cached project association diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index e54670198be..27fc84fba41 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -354,6 +354,28 @@ export class WorkflowService { publicApi: false, }); + // Check if workflow activation status changed + const wasActive = workflow.active; + const isNowActive = updatedWorkflow.active; + + if (isNowActive && !wasActive) { + // Workflow is being activated + this.eventService.emit('workflow-activated', { + user, + workflowId, + workflow: updatedWorkflow, + publicApi: false, + }); + } else if (!isNowActive && wasActive) { + // Workflow is being deactivated + this.eventService.emit('workflow-deactivated', { + user, + workflowId, + workflow: updatedWorkflow, + publicApi: false, + }); + } + if (updatedWorkflow.active) { // When the workflow is supposed to be active add it again try { @@ -370,6 +392,14 @@ export class WorkflowService { // Also set it in the returned data updatedWorkflow.active = false; + // Emit deactivation event since activation failed + this.eventService.emit('workflow-deactivated', { + user, + workflowId, + workflow: updatedWorkflow, + publicApi: false, + }); + let message; if (error instanceof NodeApiError) message = error.description; message = message ?? (error as Error).message; diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 98e1cdd5bc3..842466820d5 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -166,7 +166,6 @@ export class WorkflowsController { project.id, transactionManager, ); - // @ts-ignore CAT-957 await transactionManager.update(WorkflowEntity, { id: workflow.id }, { parentFolder }); } catch {} } diff --git a/packages/cli/test/integration/access-control/cross-project-access.test.ts b/packages/cli/test/integration/access-control/cross-project-access.test.ts index adf47b1603b..dabf6227a4a 100644 --- a/packages/cli/test/integration/access-control/cross-project-access.test.ts +++ b/packages/cli/test/integration/access-control/cross-project-access.test.ts @@ -4,6 +4,7 @@ import { randomCredentialPayload, createWorkflow, mockInstance, + testDb, } from '@n8n/backend-test-utils'; import type { Project, User, Role } from '@n8n/db'; @@ -126,6 +127,7 @@ describe('Cross-Project Access Control Tests', () => { }); afterAll(async () => { + await testDb.truncate(['User']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts b/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts index a4f15d5ffc7..a68a1764072 100644 --- a/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts +++ b/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts @@ -166,6 +166,7 @@ describe('Custom Role Functionality Tests', () => { }); afterAll(async () => { + await testDb.truncate(['User']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/access-control/resource-access-matrix.test.ts b/packages/cli/test/integration/access-control/resource-access-matrix.test.ts index 4b4273511ff..d1980b06d03 100644 --- a/packages/cli/test/integration/access-control/resource-access-matrix.test.ts +++ b/packages/cli/test/integration/access-control/resource-access-matrix.test.ts @@ -135,6 +135,7 @@ describe('Resource Access Control Matrix Tests', () => { }); afterAll(async () => { + await testDb.truncate(['User']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/controllers/role.controller-db.test.ts b/packages/cli/test/integration/controllers/role.controller-db.test.ts index b7670ebd849..c78480da0b8 100644 --- a/packages/cli/test/integration/controllers/role.controller-db.test.ts +++ b/packages/cli/test/integration/controllers/role.controller-db.test.ts @@ -10,7 +10,9 @@ import { PROJECT_EDITOR_ROLE, PROJECT_OWNER_ROLE, PROJECT_VIEWER_ROLE, + RoleRepository, } from '@n8n/db'; +import { Container } from '@n8n/di'; describe('RoleController - Integration Tests', () => { const testServer = setupTestServer({ endpointGroups: ['role'] }); @@ -32,6 +34,10 @@ describe('RoleController - Integration Tests', () => { afterEach(async () => { await cleanupRolesAndScopes(); + // Clear custom roles + await Container.get(RoleRepository).delete({ + systemRole: false, + }); }); afterAll(async () => { diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts index 3ff02377277..dfa537964f6 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -23,6 +23,7 @@ import { OidcService } from '@/sso.ee/oidc/oidc.service.ee'; import { createUser } from '@test-integration/db/users'; import { UserError } from 'n8n-workflow'; import { JwtService } from '@/services/jwt.service'; +import { GlobalConfig } from '@n8n/config'; beforeAll(async () => { await testDb.init(); @@ -276,6 +277,102 @@ describe('OIDC service', () => { expect(authUrl.nonce).toBeDefined(); }); + describe('SSO provisioning', () => { + beforeAll(async () => { + const mockConfiguration = new real_odic_client.Configuration( + { + issuer: 'https://example.com/auth/realms/n8n', + client_id: 'test-client-id', + redirect_uris: ['http://n8n.io/sso/oidc/callback'], + response_types: ['code'], + scopes: ['openid', 'profile', 'email'], + authorization_endpoint: 'https://example.com/auth', + }, + 'test-client-id', + ); + discoveryMock.mockResolvedValue(mockConfiguration); + + const initialConfig: OidcConfigDto = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + loginEnabled: true, + prompt: 'consent', + }; + + await oidcService.updateConfig(initialConfig); + }); + + let provisioningConfig: GlobalConfig['sso']['provisioning']; + + beforeEach(() => { + // safe original provisioning config, by making a copy + provisioningConfig = { + ...Container.get(GlobalConfig).sso.provisioning, + }; + }); + + afterEach(() => { + // restore original provisioning config + Container.get(GlobalConfig).sso.provisioning = provisioningConfig; + }); + + const validateUrl = (authUrl: Awaited>) => { + expect(authUrl.url.pathname).toEqual('/auth'); + expect(authUrl.url.searchParams.get('client_id')).toEqual('test-client-id'); + expect(authUrl.url.searchParams.get('redirect_uri')).toEqual( + 'http://localhost:5678/rest/sso/oidc/callback', + ); + expect(authUrl.url.searchParams.get('response_type')).toEqual('code'); + expect(authUrl.url.searchParams.get('prompt')).toBeDefined(); + expect(authUrl.url.searchParams.get('prompt')).toEqual('consent'); + expect(authUrl.url.searchParams.get('state')).toBeDefined(); + expect(authUrl.url.searchParams.get('state')?.startsWith('n8n_state:')).toBe(true); + + expect(authUrl.state).toBeDefined(); + expect(authUrl.nonce).toBeDefined(); + }; + + it('should not include the provisioning scope if no provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = false; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = false; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile'); + }); + + it('should include the provisioning scope if project provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = true; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = false; + Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope'); + }); + + it('should include the provisioning scope if instance provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = false; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = true; + Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope'); + }); + + it('should include the provisioning scope if project and instance provisioning is enabled', async () => { + Container.get(GlobalConfig).sso.provisioning.scopesProvisionProjectRoles = true; + Container.get(GlobalConfig).sso.provisioning.scopesProvisionInstanceRole = true; + Container.get(GlobalConfig).sso.provisioning.scopesName = 'n8n_test_scope'; + const authUrl = await oidcService.generateLoginUrl(); + + validateUrl(authUrl); + expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope'); + }); + }); + describe('loginUser', () => { it('should handle new user login with valid callback URL', async () => { const state = oidcService.generateState(); diff --git a/packages/cli/test/integration/services/role.service.test.ts b/packages/cli/test/integration/services/role.service.test.ts index 70627217ab1..71f3d85629f 100644 --- a/packages/cli/test/integration/services/role.service.test.ts +++ b/packages/cli/test/integration/services/role.service.test.ts @@ -53,8 +53,8 @@ afterAll(async () => { }); afterEach(async () => { - await cleanupRolesAndScopes(); await testDb.truncate(['User']); + await cleanupRolesAndScopes(); }); describe('RoleService', () => { @@ -909,6 +909,25 @@ describe('RoleService', () => { expect(result.slug).toContain('role'); expect(result.slug).toContain('name'); }); + + it('should throw BadRequestError when a role with the same display name already exists', async () => { + const testScopes = await createTestScopes(); + const createRoleDto: CreateRoleDto = { + displayName: 'Existing Role', + roleType: 'project', + scopes: [testScopes.readScope.slug], + }; + + await roleService.createCustomRole(createRoleDto); + + const duplicateRoleDto: CreateRoleDto = { + displayName: 'Existing Role', + roleType: 'project', + scopes: [testScopes.writeScope.slug], + }; + + await expect(roleService.createCustomRole(duplicateRoleDto)).rejects.toThrow(BadRequestError); + }); }); describe('updateCustomRole', () => { @@ -1032,6 +1051,25 @@ describe('RoleService', () => { 'The following scopes are invalid: invalid:scope', ); }); + + it('should throw error when a role with the same display name already exists', async () => { + // + // ARRANGE + // + const existingRole = await createRole(); + const otherExistingRole = await createRole(); + + const updateRoleDto: UpdateRoleDto = { + displayName: existingRole.displayName, + }; + + // + // ACT & ASSERT + // + await expect( + roleService.updateCustomRole(otherExistingRole.slug, updateRoleDto), + ).rejects.toThrow(`A role with the name "${existingRole.displayName}" already exists`); + }); }); describe('removeCustomRole', () => { diff --git a/packages/cli/test/integration/shared/db/roles.ts b/packages/cli/test/integration/shared/db/roles.ts index 8a221fa569c..6c0f9566d44 100644 --- a/packages/cli/test/integration/shared/db/roles.ts +++ b/packages/cli/test/integration/shared/db/roles.ts @@ -10,7 +10,7 @@ export async function createRole(overrides: Partial = {}): Promise { const defaultRole: Partial = { slug: `test-role-${Math.random().toString(36).substring(7)}`, - displayName: 'Test Role', + displayName: `Test Role ${Math.random().toString(36).substring(7)}`, description: 'A test role for integration testing', systemRole: false, roleType: 'project', @@ -164,11 +164,7 @@ export async function cleanupRolesAndScopes(): Promise { .getMany(); for (const role of testRoles) { - try { - await roleRepository.delete({ slug: role.slug }); - } catch (error) { - // Ignore errors for system roles or roles with dependencies - } + await roleRepository.delete({ slug: role.slug }); } // Delete test scopes @@ -178,10 +174,6 @@ export async function cleanupRolesAndScopes(): Promise { .getMany(); for (const scope of testScopes) { - try { - await scopeRepository.delete({ slug: scope.slug }); - } catch (error) { - // Ignore errors for scopes with dependencies - } + await scopeRepository.delete({ slug: scope.slug }); } } diff --git a/packages/cli/test/integration/shared/db/workflow-statistics.ts b/packages/cli/test/integration/shared/db/workflow-statistics.ts index d1cd64dcee9..6f4f9520a18 100644 --- a/packages/cli/test/integration/shared/db/workflow-statistics.ts +++ b/packages/cli/test/integration/shared/db/workflow-statistics.ts @@ -15,7 +15,6 @@ export async function createWorkflowStatisticsItem( workflowId, }); - // @ts-ignore CAT-957 await Container.get(WorkflowStatisticsRepository).insert(entity); return entity; diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 731a93d228a..3b40a6b7449 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1644,7 +1644,7 @@ describe('PATCH /users/:id/role', () => { test('should change to existing custom role', async () => { const customRole = 'custom:role'; - await createRole({ slug: customRole, displayName: 'Custom Role', roleType: 'global' }); + await createRole({ slug: customRole, displayName: 'Custom Role 1', roleType: 'global' }); const response = await ownerAgent.patch(`/users/${member.id}/role`).send({ newRoleName: customRole, }); diff --git a/packages/cli/test/migration/1760020838000-unique-role-names.test.ts b/packages/cli/test/migration/1760020838000-unique-role-names.test.ts new file mode 100644 index 00000000000..78bb874d70c --- /dev/null +++ b/packages/cli/test/migration/1760020838000-unique-role-names.test.ts @@ -0,0 +1,438 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; + +const MIGRATION_NAME = 'UniqueRoleNames1760020838000'; + +interface RoleData { + slug: string; + displayName: string; + createdAt: Date; + systemRole?: boolean; + roleType?: string; + description?: string | null; +} + +interface RoleRow { + slug: string; + displayName: string; + createdAt: Date; +} + +describe('UniqueRoleNames Migration', () => { + let dataSource: DataSource; + + beforeAll(async () => { + // Initialize DB connection without running migrations + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + + dataSource = Container.get(DataSource); + + // Run migrations up to (but not including) target migration + await initDbUpToMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + /** + * Helper function to insert a test role with controlled timestamp + */ + async function insertTestRole(context: TestMigrationContext, roleData: RoleData): Promise { + const tableName = context.escape.tableName('role'); + const slugColumn = context.escape.columnName('slug'); + const displayNameColumn = context.escape.columnName('displayName'); + const createdAtColumn = context.escape.columnName('createdAt'); + const updatedAtColumn = context.escape.columnName('updatedAt'); + const systemRoleColumn = context.escape.columnName('systemRole'); + const roleTypeColumn = context.escape.columnName('roleType'); + const descriptionColumn = context.escape.columnName('description'); + + const systemRole = roleData.systemRole ?? false; + const roleType = roleData.roleType ?? 'project'; + const description = roleData.description ?? null; + + await context.queryRunner.query( + `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}, ${descriptionColumn}) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + roleData.slug, + roleData.displayName, + roleData.createdAt, + roleData.createdAt, + systemRole, + roleType, + description, + ], + ); + } + + /** + * Helper function to retrieve all roles ordered by creation date + */ + async function getAllRoles(context: TestMigrationContext): Promise { + const tableName = context.escape.tableName('role'); + const slugColumn = context.escape.columnName('slug'); + const displayNameColumn = context.escape.columnName('displayName'); + const createdAtColumn = context.escape.columnName('createdAt'); + + const roles = await context.queryRunner.query( + `SELECT ${slugColumn} as slug, ${displayNameColumn} as displayName, ${createdAtColumn} as createdAt FROM ${tableName} ORDER BY ${createdAtColumn} ASC`, + ); + + return roles; + } + + describe('Schema Migration', () => { + it('should create unique index and correctly rename all duplicate roles', async () => { + // Create migration context for schema queries + const context = createTestMigrationContext(dataSource); + + // Test Scenario 1: 3 roles with same displayName "Duplicate Name" + await insertTestRole(context, { + slug: 'test-role-oldest', + displayName: 'Duplicate Name', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + }); + await insertTestRole(context, { + slug: 'test-role-middle', + displayName: 'Duplicate Name', + createdAt: new Date('2024-01-02T00:00:00.000Z'), + }); + await insertTestRole(context, { + slug: 'test-role-newest', + displayName: 'Duplicate Name', + createdAt: new Date('2024-01-03T00:00:00.000Z'), + }); + + // Test Scenario 2: 2 duplicate "Editor" roles + await insertTestRole(context, { + slug: 'editor-first', + displayName: 'Editor', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }); + await insertTestRole(context, { + slug: 'editor-second', + displayName: 'Editor', + createdAt: new Date('2025-01-02T00:00:00.000Z'), + }); + + // Test Scenario 3: 5 duplicate "Manager" roles + for (let i = 1; i <= 5; i++) { + await insertTestRole(context, { + slug: `manager-${i}`, + displayName: 'Manager', + createdAt: new Date(`2025-02-0${i}T00:00:00.000Z`), + }); + } + + // Test Scenario 4: Multiple independent duplicate groups + // Group 1: 3 "Admin" roles + await insertTestRole(context, { + slug: 'admin-1', + displayName: 'Admin', + createdAt: new Date('2025-03-01T00:00:00.000Z'), + }); + await insertTestRole(context, { + slug: 'admin-2', + displayName: 'Admin', + createdAt: new Date('2025-03-02T00:00:00.000Z'), + }); + await insertTestRole(context, { + slug: 'admin-3', + displayName: 'Admin', + createdAt: new Date('2025-03-03T00:00:00.000Z'), + }); + + // Group 2: 2 "Reviewer" roles + await insertTestRole(context, { + slug: 'reviewer-1', + displayName: 'Reviewer', + createdAt: new Date('2025-03-04T00:00:00.000Z'), + }); + await insertTestRole(context, { + slug: 'reviewer-2', + displayName: 'Reviewer', + createdAt: new Date('2025-03-05T00:00:00.000Z'), + }); + + // Check conflict with generated display name conflict + await insertTestRole(context, { + slug: 'reviewer-3', + displayName: 'Reviewer 2', + createdAt: new Date('2025-03-05T00:00:00.000Z'), + }); + + // Group 3: 1 "Viewer" role (no duplicates) + await insertTestRole(context, { + slug: 'viewer-1', + displayName: 'Viewer', + createdAt: new Date('2025-03-06T00:00:00.000Z'), + }); + + // Verify pre-migration state - all roles exist with original displayNames + const beforeRoles = await getAllRoles(context); + expect(beforeRoles.filter((r) => r.displayName === 'Duplicate Name')).toHaveLength(3); + expect(beforeRoles.filter((r) => r.displayName === 'Editor')).toHaveLength(2); + expect(beforeRoles.filter((r) => r.displayName === 'Manager')).toHaveLength(5); + expect(beforeRoles.filter((r) => r.displayName === 'Admin')).toHaveLength(3); + expect(beforeRoles.filter((r) => r.displayName === 'Reviewer')).toHaveLength(2); + expect(beforeRoles.filter((r) => r.displayName === 'Viewer')).toHaveLength(1); + + // Run the migration + await runSingleMigration(MIGRATION_NAME); + + // Release old query runner before creating new one + await context.queryRunner.release(); + + // Create fresh context after migration + const postMigrationContext = createTestMigrationContext(dataSource); + + const tableName = postMigrationContext.escape.tableName('role'); + const displayNameColumn = postMigrationContext.escape.columnName('displayName'); + const slugColumn = postMigrationContext.escape.columnName('slug'); + const indexName = postMigrationContext.escape.indexName('UniqueRoleDisplayName'); + + // Verify all duplicate roles were renamed correctly + const afterRoles = await getAllRoles(postMigrationContext); + + // Test Scenario 1: 3 "Duplicate Name" roles + const oldestRole = afterRoles.find((r) => r.slug === 'test-role-oldest'); + const middleRole = afterRoles.find((r) => r.slug === 'test-role-middle'); + const newestRole = afterRoles.find((r) => r.slug === 'test-role-newest'); + expect(oldestRole?.displayName).toBe('Duplicate Name'); // Oldest keeps original + expect(middleRole?.displayName).toBe('Duplicate Name 2'); // Second gets " 2" + expect(newestRole?.displayName).toBe('Duplicate Name 3'); // Third gets " 3" + + // Test Scenario 2: 2 "Editor" roles + const editorFirst = afterRoles.find((r) => r.slug === 'editor-first'); + const editorSecond = afterRoles.find((r) => r.slug === 'editor-second'); + expect(editorFirst?.displayName).toBe('Editor'); // Oldest keeps original + expect(editorSecond?.displayName).toBe('Editor 2'); // Second gets " 2" + + // Test Scenario 3: 5 "Manager" roles + const manager1 = afterRoles.find((r) => r.slug === 'manager-1'); + const manager2 = afterRoles.find((r) => r.slug === 'manager-2'); + const manager3 = afterRoles.find((r) => r.slug === 'manager-3'); + const manager4 = afterRoles.find((r) => r.slug === 'manager-4'); + const manager5 = afterRoles.find((r) => r.slug === 'manager-5'); + expect(manager1?.displayName).toBe('Manager'); // Oldest keeps original + expect(manager2?.displayName).toBe('Manager 2'); + expect(manager3?.displayName).toBe('Manager 3'); + expect(manager4?.displayName).toBe('Manager 4'); + expect(manager5?.displayName).toBe('Manager 5'); + + // Test Scenario 4: Multiple independent groups + const admin1 = afterRoles.find((r) => r.slug === 'admin-1'); + const admin2 = afterRoles.find((r) => r.slug === 'admin-2'); + const admin3 = afterRoles.find((r) => r.slug === 'admin-3'); + expect(admin1?.displayName).toBe('Admin'); + expect(admin2?.displayName).toBe('Admin 2'); + expect(admin3?.displayName).toBe('Admin 3'); + + const reviewer1 = afterRoles.find((r) => r.slug === 'reviewer-1'); + const reviewer2 = afterRoles.find((r) => r.slug === 'reviewer-2'); + expect(reviewer1?.displayName).toBe('Reviewer'); + expect(reviewer2?.displayName).toBe('Reviewer 3'); + + const reviewer3 = afterRoles.find((r) => r.slug === 'reviewer-3'); + expect(reviewer3?.displayName).toBe('Reviewer 2'); + + const viewer1 = afterRoles.find((r) => r.slug === 'viewer-1'); + expect(viewer1?.displayName).toBe('Viewer'); // Unchanged (no duplicates) + + // Verify unique index exists based on database type + if (postMigrationContext.isSqlite) { + const indexes = await postMigrationContext.queryRunner.query( + `PRAGMA index_list(${tableName})`, + ); + const uniqueIndex = indexes.find( + (idx: { name: string; unique: number }) => + idx.name.includes('UniqueRoleDisplayName') && idx.unique === 1, + ); + expect(uniqueIndex).toBeDefined(); + } else if (postMigrationContext.isPostgres) { + const result = await postMigrationContext.queryRunner.query( + `SELECT indexname FROM pg_indexes WHERE tablename = ${tableName} AND indexname = ${indexName}`, + ); + expect(result).toHaveLength(1); + + // Verify index is unique + const uniqueCheck = await postMigrationContext.queryRunner.query( + `SELECT i.relname as index_name, ix.indisunique + FROM pg_class t + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + WHERE t.relname = ${tableName} AND i.relname = ${indexName}`, + ); + expect(uniqueCheck[0].indisunique).toBe(true); + } else if (postMigrationContext.isMysql) { + const result = await postMigrationContext.queryRunner.query( + `SHOW INDEXES FROM ${tableName} WHERE Key_name = ${indexName}`, + ); + expect(result).toHaveLength(1); + expect(result[0].Non_unique).toBe(0); // 0 means unique + } + + // Verify index enforces uniqueness by attempting duplicate insert + await postMigrationContext.queryRunner.query( + `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${postMigrationContext.escape.columnName('createdAt')}, ${postMigrationContext.escape.columnName('updatedAt')}, ${postMigrationContext.escape.columnName('systemRole')}, ${postMigrationContext.escape.columnName('roleType')}) VALUES (?, ?, ?, ?, ?, ?)`, + ['test-duplicate-attempt', 'Unique Test Name', new Date(), new Date(), false, 'project'], + ); + + const attemptDuplicateInsert = async () => { + return await postMigrationContext.queryRunner.query( + `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${postMigrationContext.escape.columnName('createdAt')}, ${postMigrationContext.escape.columnName('updatedAt')}, ${postMigrationContext.escape.columnName('systemRole')}, ${postMigrationContext.escape.columnName('roleType')}) VALUES (?, ?, ?, ?, ?, ?)`, + [ + 'test-duplicate-attempt-2', + 'Unique Test Name', + new Date(), + new Date(), + false, + 'project', + ], + ); + }; + + await expect(attemptDuplicateInsert()).rejects.toThrow(); + + // Cleanup + await postMigrationContext.queryRunner.release(); + }); + + it('should remove unique index on rollback', async () => { + // NOTE: This test skips duplicate scenarios since migration already ran in previous test + // We're testing rollback functionality independently + + // Run up() migration first (already done in test set up) + // runSingleMigration checks if already executed and skips if needed + await runSingleMigration(MIGRATION_NAME); + + // Create fresh context + const upContext = createTestMigrationContext(dataSource); + + const tableName = upContext.escape.tableName('role'); + const indexName = upContext.escape.indexName('UniqueRoleDisplayName'); + + // Verify unique index exists + if (upContext.isSqlite) { + const indexes = await upContext.queryRunner.query(`PRAGMA index_list(${tableName})`); + const uniqueIndex = indexes.find( + (idx: { name: string; unique: number }) => + idx.name.includes('UniqueRoleDisplayName') && idx.unique === 1, + ); + expect(uniqueIndex).toBeDefined(); + } else if (upContext.isPostgres) { + const result = await upContext.queryRunner.query( + `SELECT indexname FROM pg_indexes WHERE tablename = ${tableName} AND indexname = ${indexName}`, + ); + expect(result).toHaveLength(1); + } else if (upContext.isMysql) { + const result = await upContext.queryRunner.query( + `SHOW INDEXES FROM ${tableName} WHERE Key_name = ${indexName}`, + ); + expect(result).toHaveLength(1); + } + + await upContext.queryRunner.release(); + + await undoLastSingleMigration(); + + // Create fresh context after rollback + const postRollbackContext = createTestMigrationContext(dataSource); + + // Verify index is removed (DB-specific queries) + if (postRollbackContext.isSqlite) { + const indexes = await postRollbackContext.queryRunner.query( + `PRAGMA index_list(${tableName})`, + ); + const uniqueIndex = indexes.find( + (idx: { name: string; unique: number }) => + idx.name.includes('UniqueRoleDisplayName') && idx.unique === 1, + ); + expect(uniqueIndex).toBeUndefined(); + } else if (postRollbackContext.isPostgres) { + const result = await postRollbackContext.queryRunner.query( + `SELECT indexname FROM pg_indexes WHERE tablename = ${tableName} AND indexname = ${indexName}`, + ); + expect(result).toHaveLength(0); + } else if (postRollbackContext.isMysql) { + const result = await postRollbackContext.queryRunner.query( + `SHOW INDEXES FROM ${tableName} WHERE Key_name = ${indexName}`, + ); + expect(result).toHaveLength(0); + } + + // Verify duplicate displayNames can be inserted again + // Insert 2 roles with same displayName to confirm duplicates allowed + const slugColumn = postRollbackContext.escape.columnName('slug'); + const displayNameColumn = postRollbackContext.escape.columnName('displayName'); + const createdAtColumn = postRollbackContext.escape.columnName('createdAt'); + const updatedAtColumn = postRollbackContext.escape.columnName('updatedAt'); + const systemRoleColumn = postRollbackContext.escape.columnName('systemRole'); + const roleTypeColumn = postRollbackContext.escape.columnName('roleType'); + + await postRollbackContext.queryRunner.query( + `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}) VALUES (?, ?, ?, ?, ?, ?)`, + ['rollback-test-1', 'Duplicate After Rollback', new Date(), new Date(), false, 'project'], + ); + + await postRollbackContext.queryRunner.query( + `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}) VALUES (?, ?, ?, ?, ?, ?)`, + ['rollback-test-2', 'Duplicate After Rollback', new Date(), new Date(), false, 'project'], + ); + + // Verify both roles were inserted successfully + const duplicateRoles = await postRollbackContext.queryRunner.query( + `SELECT ${slugColumn} as slug, ${displayNameColumn} as displayName FROM ${tableName} WHERE ${displayNameColumn} = ?`, + ['Duplicate After Rollback'], + ); + + expect(duplicateRoles).toHaveLength(2); + + // Cleanup + await postRollbackContext.queryRunner.release(); + }); + }); + + describe('Post-Migration Capacity', () => { + it('should accept unique displayNames after migration', async () => { + const context = createTestMigrationContext(dataSource); + + const tableName = context.escape.tableName('role'); + const slugColumn = context.escape.columnName('slug'); + const displayNameColumn = context.escape.columnName('displayName'); + const createdAtColumn = context.escape.columnName('createdAt'); + const updatedAtColumn = context.escape.columnName('updatedAt'); + const systemRoleColumn = context.escape.columnName('systemRole'); + const roleTypeColumn = context.escape.columnName('roleType'); + + // Insert role with unique displayName + await context.queryRunner.query( + `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}) VALUES (?, ?, ?, ?, ?, ?)`, + ['unique-role-test', 'Unique Role Name', new Date(), new Date(), false, 'project'], + ); + + // Verify retrieval using SQL + const [result] = await context.queryRunner.query( + `SELECT ${slugColumn} as slug, ${displayNameColumn} as displayName FROM ${tableName} WHERE ${slugColumn} = ?`, + ['unique-role-test'], + ); + + expect(result).toBeDefined(); + expect(result.displayName).toBe('Unique Role Name'); + + // Cleanup + await context.queryRunner.release(); + }); + }); +}); diff --git a/packages/cli/test/migration/migration-test-helpers.test.ts b/packages/cli/test/migration/migration-test-helpers.test.ts new file mode 100644 index 00000000000..c7468f0d602 --- /dev/null +++ b/packages/cli/test/migration/migration-test-helpers.test.ts @@ -0,0 +1,69 @@ +import { initDbUpToMigration, runSingleMigration } from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { UnexpectedError } from 'n8n-workflow'; + +describe('Migration Test Helpers', () => { + let dataSource: DataSource; + + beforeEach(async () => { + // Initialize connection without running migrations + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + dataSource = Container.get(DataSource); + }); + + afterEach(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + describe('initDbUpToMigration', () => { + it('should throw error if migration not found', async () => { + await expect(initDbUpToMigration('NonExistentMigration')).rejects.toThrow( + new UnexpectedError('Migration "NonExistentMigration" not found'), + ); + }); + + it('should stop before specified migration', async () => { + const migrations = dataSource.options.migrations as Array<{ name: string }>; + expect(migrations.length).toBeGreaterThan(1); + + const secondMigrationName = migrations[1].name; + console.log('Running migrations up to ' + secondMigrationName); + await initDbUpToMigration(secondMigrationName); + console.log('Migrations executed up to ' + secondMigrationName); + + // Verify only first migration was executed + const executed = await dataSource.query('SELECT * FROM migrations ORDER BY timestamp'); + expect(executed).toHaveLength(1); + expect(executed[0].name).toBe(migrations[0].name); + }); + }); + + describe('runSingleMigration', () => { + it('should throw error if migration not found', async () => { + await expect(runSingleMigration('NonExistentMigration')).rejects.toThrow( + new UnexpectedError('Migration "NonExistentMigration" not found'), + ); + }); + + it('should run specific migration', async () => { + const migrations = dataSource.options.migrations as Array<{ name: string }>; + expect(migrations.length).toBeGreaterThan(1); + + const secondMigrationName = migrations[1].name; + console.log('Running migrations up to ' + secondMigrationName); + await initDbUpToMigration(secondMigrationName); + console.log('Migrations executed up to ' + secondMigrationName); + + await runSingleMigration(secondMigrationName); + + const executed = await dataSource.query('SELECT * FROM migrations ORDER BY timestamp'); + expect(executed).toHaveLength(2); + expect(executed[0].name).toBe(migrations[0].name); + expect(executed[1].name).toBe(secondMigrationName); + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 3f294a380cf..086aeff71ef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.114.0", + "version": "1.115.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/src/execution-engine/__tests__/mock-node-types.ts b/packages/core/src/execution-engine/__tests__/mock-node-types.ts index bd9647c862e..f340ec4ddae 100644 --- a/packages/core/src/execution-engine/__tests__/mock-node-types.ts +++ b/packages/core/src/execution-engine/__tests__/mock-node-types.ts @@ -140,7 +140,7 @@ export function modifyNode(originalNode: INodeType): NodeModifier { // Handle function responses (for Response parameter injection) if (typeof predefinedResponse === 'function') { - return predefinedResponse(response); + return predefinedResponse.call(this, response); } return predefinedResponse; diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts index 4081c6ba1e4..4324832d86a 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts @@ -5,8 +5,11 @@ import type { IWorkflowExecuteAdditionalData, EngineResponse, WorkflowExecuteMode, + IExecuteFunctions, + IPairedItemData, + INodeExecutionData, } from 'n8n-workflow'; -import { ApplicationError } from 'n8n-workflow'; +import { ApplicationError, NodeConnectionTypes } from 'n8n-workflow'; import { NodeTypes } from '@test/helpers'; @@ -121,6 +124,79 @@ describe('processRunExecutionData', () => { expect(runHook).toHaveBeenNthCalledWith(6, 'workflowExecuteAfter', expect.any(Array)); }); + test('agent node emits nodeExecuteBefore only once when resuming after tool execution', async () => { + // ARRANGE + // Create agent node that returns EngineRequest, then resumes with tool results + const agentNodeType = modifyNode(passThroughNode) + .return({ + actions: [ + { + actionType: 'ExecutionNodeAction', + nodeName: 'tool', + input: { query: 'test input' }, + type: 'ai_tool', + id: 'action_1', + metadata: {}, + }, + ], + metadata: {}, + }) + .return((response) => [[{ json: { result: 'agent completed', response } }]]) + .done(); + + const trigger = createNodeData({ name: 'trigger', type: types.passThrough }); + const agent = createNodeData({ name: 'agent', type: 'agent' }); + const tool = createNodeData({ name: 'tool', type: types.passThrough }); + + const nodeTypes = NodeTypes({ + ...nodeTypeArguments, + agent: { type: agentNodeType, sourcePath: '' }, + }); + + const workflowInstance = new DirectedGraph() + .addNodes(trigger, agent, tool) + .addConnections( + { from: trigger, to: agent }, + { from: tool, to: agent, type: NodeConnectionTypes.AiTool }, + ) + .toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } }); + + const taskDataConnection = { main: [[{ json: { foo: 1 } }]] }; + const executionData: IRunExecutionData = { + startData: { startNodes: [{ name: trigger.name, sourceData: null }] }, + resultData: { runData: {} }, + executionData: { + contextData: {}, + nodeExecutionStack: [{ data: taskDataConnection, node: trigger, source: null }], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + + const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData); + + // ACT + await workflowExecute.processRunExecutionData(workflowInstance); + + // ASSERT + expect( + runHook.mock.calls.map((hook: [string, unknown[]]) => ({ + name: hook[0], + node: typeof hook[1][0] === 'string' ? hook[1][0] : undefined, + })), + ).toEqual([ + { name: 'workflowExecuteBefore' }, + { name: 'nodeExecuteBefore', node: 'trigger' }, + { name: 'nodeExecuteAfter', node: 'trigger' }, + { name: 'nodeExecuteBefore', node: 'agent' }, + { name: 'nodeExecuteBefore', node: 'tool' }, + { name: 'nodeExecuteAfter', node: 'tool' }, + { name: 'nodeExecuteAfter', node: 'agent' }, + { name: 'workflowExecuteAfter' }, + ]); + }); + describe('runExecutionData.waitTill', () => { test('handles waiting state properly when waitTill is set', async () => { // ARRANGE @@ -352,13 +428,44 @@ describe('processRunExecutionData', () => { // Tool nodes should have been added to runData with inputOverride expect(runData[tool1Node.name]).toHaveLength(1); + expect(runData[tool1Node.name][0].inputOverride).toEqual({ - ai_tool: [[{ json: { query: 'test input', toolCallId: 'action_1' } }]], + ai_tool: [ + [ + { + json: { query: 'test input', toolCallId: 'action_1' }, + pairedItem: { + input: 0, + item: 0, + sourceOverwrite: { + previousNode: 'Start', + previousNodeOutput: 0, + previousNodeRun: 0, + }, + }, + }, + ], + ], }); expect(runData[tool2Node.name]).toHaveLength(1); expect(runData[tool2Node.name][0].inputOverride).toEqual({ - ai_tool: [[{ json: { data: 'another input', toolCallId: 'action_2' } }]], + ai_tool: [ + [ + { + json: { data: 'another input', toolCallId: 'action_2' }, + pairedItem: { + input: 0, + item: 0, + sourceOverwrite: { + previousNode: 'Start', + previousNodeOutput: 0, + previousNodeRun: 0, + }, + }, + }, + ], + ], }); // Tools should have executed successfully @@ -455,7 +562,22 @@ describe('processRunExecutionData', () => { // 2. Tool nodes get added to runData with inputOverride but are never actually executed expect(runData[tool1Node.name]).toHaveLength(1); expect(runData[tool1Node.name][0].inputOverride).toEqual({ - ai_tool: [[{ json: { query: 'test input', toolCallId: 'action_1' } }]], + ai_tool: [ + [ + { + json: { query: 'test input', toolCallId: 'action_1' }, + pairedItem: { + input: 0, + item: 0, + sourceOverwrite: { + previousNode: 'nodeWithRequests', + previousNodeOutput: 0, + previousNodeRun: 0, + }, + }, + }, + ], + ], }); // The tool node should not have execution data since it was never run expect(runData[tool1Node.name][0].data).toBeUndefined(); @@ -630,4 +752,230 @@ describe('processRunExecutionData', () => { expect(result.data.resultData.lastNodeExecuted).toBeUndefined(); }); }); + + describe('pairedItem sourceOverwrite handling', () => { + test('preserves sourceOverwrite for tools to enable expression resolution', async () => { + // Test: DataNode → AgentNode → ToolNode where ToolNode accesses DataNode via expressions + const dataNodeOutput = { field: 'testValue', nested: { value: 42 } }; + const dataNode = createNodeData({ name: 'DataNode', type: types.passThrough }); + + const toolNodeType = modifyNode(passThroughNode) + .return(function (this: IExecuteFunctions, response?: EngineResponse) { + try { + const proxy = this.getWorkflowDataProxy(0); + const connectionInputData = + (this as IExecuteFunctions & { connectionInputData: INodeExecutionData[] }) + .connectionInputData || []; + const firstItem = connectionInputData[0]; + const pairedItem = (firstItem?.pairedItem as IPairedItemData) ?? { item: 0 }; + const sourceData = this.getExecuteData().source?.main?.[0] ?? null; + + const dataNodeItem = proxy.$getPairedItem('DataNode', sourceData, pairedItem); + const fieldValue = dataNodeItem?.json?.field; + const nestedValue = (dataNodeItem?.json?.nested as IDataObject)?.value; + + return [ + [ + { + json: { + toolResult: 'Tool executed successfully', + dataNodeField: fieldValue, + dataNodeNested: nestedValue, + response, + }, + }, + ], + ]; + } catch (error) { + return [ + [ + { + json: { + toolResult: 'Failed to access DataNode', + error: (error as Error).message, + response, + }, + }, + ], + ]; + } + }) + .done(); + const toolNode = createNodeData({ name: 'ToolNode', type: 'toolNodeType' }); + + const agentNodeType = modifyNode(passThroughNode) + .return({ + actions: [ + { + actionType: 'ExecutionNodeAction', + nodeName: toolNode.name, + input: { query: 'test query' }, + type: 'ai_tool', + id: 'tool_action_1', + metadata: {}, + }, + ], + metadata: { requestId: 'test_agent_request' }, + }) + .return((response?: EngineResponse) => { + return [[{ json: { agentResult: 'Agent completed', response } }]]; + }) + .done(); + const agentNode = createNodeData({ name: 'AgentNode', type: 'agentNodeType' }); + + const customNodeTypes = NodeTypes({ + ...nodeTypeArguments, + agentNodeType: { type: agentNodeType, sourcePath: '' }, + toolNodeType: { type: toolNodeType, sourcePath: '' }, + }); + + const workflow = new DirectedGraph() + .addNodes(dataNode, agentNode, toolNode) + .addConnections({ from: dataNode, to: agentNode }) + .addConnections({ from: toolNode, to: agentNode, type: 'ai_tool' }) + .toWorkflow({ + name: '', + active: false, + nodeTypes: customNodeTypes, + settings: { executionOrder: 'v1' }, + }); + + const taskDataConnection = { main: [[{ json: dataNodeOutput }]] }; + const executionData: IRunExecutionData = { + startData: { startNodes: [{ name: dataNode.name, sourceData: null }] }, + resultData: { runData: {} }, + executionData: { + contextData: {}, + nodeExecutionStack: [{ data: taskDataConnection, node: dataNode, source: null }], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + + const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData); + + const result = await workflowExecute.processRunExecutionData(workflow); + const runData = result.data.resultData.runData; + + // Verify preserveSourceOverwrite metadata is set + expect(runData[toolNode.name][0].metadata?.preserveSourceOverwrite).toBeDefined(); + + // Verify sourceOverwrite points to DataNode + const toolInput = runData[toolNode.name][0].inputOverride?.ai_tool?.[0]?.[0]; + expect(toolInput?.pairedItem).toBeDefined(); + if (typeof toolInput?.pairedItem === 'object' && !Array.isArray(toolInput.pairedItem)) { + expect(toolInput.pairedItem.sourceOverwrite?.previousNode).toBe(dataNode.name); + } + + // Verify tool successfully accessed DataNode data via sourceOverwrite + const toolOutput = runData[toolNode.name][0].data?.ai_tool?.[0]?.[0]?.json; + expect(toolOutput?.toolResult).toBe('Tool executed successfully'); + expect(toolOutput?.dataNodeField).toBe('testValue'); + expect(toolOutput?.dataNodeNested).toBe(42); + expect(toolOutput).not.toHaveProperty('error'); + }); + + test('sourceOverwrite works correctly in loop scenarios', async () => { + // Test: TriggerNode → LoopNode → DataNode → IFNode + // IFNode evaluates $('DataNode').item.json.email + const triggerData = { email: 'test@example.com', name: 'Test User' }; + const triggerNode = createNodeData({ name: 'TriggerNode', type: types.passThrough }); + + let loopIteration = 0; + const loopNodeType = modifyNode(passThroughNode) + .return(function (this: IExecuteFunctions) { + const items = this.getInputData(); + loopIteration++; + + return [ + items.map((item, index) => ({ + json: item.json, + pairedItem: { + item: index, + input: 0, + sourceOverwrite: { + previousNode: triggerNode.name, + previousNodeOutput: 0, + previousNodeRun: 0, + }, + }, + })), + ]; + }) + .done(); + const loopNode = createNodeData({ name: 'LoopNode', type: 'loopNodeType' }); + const dataNode = createNodeData({ name: 'DataNode', type: types.passThrough }); + + let expressionError: Error | undefined; + const ifNodeType = modifyNode(passThroughNode) + .return(function (this: IExecuteFunctions) { + try { + const proxy = this.getWorkflowDataProxy(0); + const connectionInputData = + (this as IExecuteFunctions & { connectionInputData: INodeExecutionData[] }) + .connectionInputData ?? []; + const firstItem = connectionInputData[0]; + const pairedItem = (firstItem?.pairedItem as IPairedItemData) ?? { item: 0 }; + const sourceData = this.getExecuteData().source?.main?.[0] ?? null; + + const dataNodeItem = proxy.$getPairedItem('DataNode', sourceData, pairedItem); + const email = dataNodeItem?.json?.email; + + return [ + [ + { + json: { + result: 'Expression resolved', + email, + iteration: loopIteration, + }, + }, + ], + ]; + } catch (error) { + expressionError = error; + throw error; + } + }) + .done(); + const ifNode = createNodeData({ name: 'IFNode', type: 'ifNodeType' }); + + const customNodeTypes = NodeTypes({ + ...nodeTypeArguments, + loopNodeType: { type: loopNodeType, sourcePath: '' }, + ifNodeType: { type: ifNodeType, sourcePath: '' }, + }); + + const workflow = new DirectedGraph() + .addNodes(triggerNode, loopNode, dataNode, ifNode) + .addConnections({ from: triggerNode, to: loopNode }) + .addConnections({ from: loopNode, to: dataNode }) + .addConnections({ from: dataNode, to: ifNode }) + .toWorkflow({ + name: '', + active: false, + nodeTypes: customNodeTypes, + settings: { executionOrder: 'v1' }, + }); + + const taskDataConnection = { main: [[{ json: triggerData }]] }; + const executionData: IRunExecutionData = { + startData: { startNodes: [{ name: triggerNode.name, sourceData: null }] }, + resultData: { runData: {} }, + executionData: { + contextData: {}, + nodeExecutionStack: [{ data: taskDataConnection, node: triggerNode, source: null }], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }; + + const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData); + + await expect(workflowExecute.processRunExecutionData(workflow)).resolves.toBeTruthy(); + expect(expressionError).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/execution-engine/requests-response.ts b/packages/core/src/execution-engine/requests-response.ts index b32bf41765f..dbdaa8a5446 100644 --- a/packages/core/src/execution-engine/requests-response.ts +++ b/packages/core/src/execution-engine/requests-response.ts @@ -31,6 +31,7 @@ function prepareRequestedNodesForExecution( request: EngineRequest, runIndex: number, runData: IRunData, + executionData: IExecuteData, ) { // 1. collect nodes to be put on the stack const nodesToBeExecuted: NodeToBeExecuted[] = []; @@ -56,6 +57,10 @@ function prepareRequestedNodesForExecution( index: 0, }; const parentNode = currentNode.name; + const parentSourceData = executionData.source?.main?.[runIndex]; + const parentOutputIndex = parentSourceData?.previousNodeOutput ?? 0; + const parentRunIndex = parentSourceData?.previousNodeRun ?? 0; + const parentSourceNode = parentSourceData?.previousNode ?? currentNode.name; const parentOutputData: INodeExecutionData[][] = [ [ { @@ -63,10 +68,18 @@ function prepareRequestedNodesForExecution( ...action.input, toolCallId: action.id, }, + pairedItem: { + item: parentRunIndex, + input: parentOutputIndex, + sourceOverwrite: { + previousNode: parentSourceNode, + previousNodeOutput: parentOutputIndex, + previousNodeRun: parentRunIndex, + }, + }, }, ], ]; - const parentOutputIndex = 0; runData[node.name] ||= []; const nodeRunData = runData[node.name]; @@ -88,6 +101,7 @@ function prepareRequestedNodesForExecution( parentOutputData, runIndex, nodeRunIndex, + metadata: { preserveSourceOverwrite: true }, }); subNodeExecutionData.actions.push({ action, @@ -166,6 +180,7 @@ export function handleRequest({ request, runIndex, runData, + executionData, ); // 2. create metadata for current node @@ -182,7 +197,7 @@ export function handleRequest({ parentOutputData: executionData.data.main as INodeExecutionData[][], runIndex, nodeRunIndex: runIndex, - metadata: { subNodeExecutionData }, + metadata: { nodeWasResumed: true, subNodeExecutionData }, }); return { nodesToBeExecuted }; diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index b2597ccf900..c3c63e8ac08 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -1565,6 +1565,29 @@ export class WorkflowExecute { } return input.map((item, itemIndex) => { + // Preserve any existing sourceOverwrite from the pairedItem + // for tool executions. Tool calls don't have a main + // connection to the agent's input, so the data proxy needs + // the sourceOverwrite information to know where to look up + // paired items. This is necessary because the workflow data + // proxy works on input data which normally scrubs paired + // item information before executing the node. + const isToolExecution = !!executionData.metadata?.preserveSourceOverwrite; + if ( + isToolExecution && + typeof item.pairedItem === 'object' && + 'sourceOverwrite' in item.pairedItem + ) { + return { + ...item, + pairedItem: { + item: itemIndex, + input: inputIndex || undefined, + sourceOverwrite: item.pairedItem.sourceOverwrite, + }, + }; + } + return { ...item, pairedItem: { @@ -1613,7 +1636,16 @@ export class WorkflowExecute { node: executionNode.name, workflowId: workflow.id, }); - await hooks.runHook('nodeExecuteBefore', [executionNode.name, taskStartedData]); + // Skip nodeExecuteBefore for resumed agent nodes to prevent duplicate event emission. + // Context: AI agents pause execution to run tools, then resume with tool results. + // Without this check, the agent would emit nodeExecuteBefore twice (initial + resume) + // but only one nodeExecuteAfter, causing frontend spinner state to become stuck. + // See: AI-1414 + // Future: May introduce dedicated nodeExecutionPaused/nodeExecutionResumed events + // if we need finer-grained visibility into the pause/resume cycle. + if (!executionData.metadata?.nodeWasResumed) { + await hooks.runHook('nodeExecuteBefore', [executionNode.name, taskStartedData]); + } let maxTries = 1; if (executionData.node.retryOnFail === true) { // TODO: Remove the hardcoded default-values here and also in NodeSettings.vue diff --git a/packages/extensions/insights/package.json b/packages/extensions/insights/package.json index e033e2e56fd..111414c166e 100644 --- a/packages/extensions/insights/package.json +++ b/packages/extensions/insights/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-extension-insights", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "files": [ "dist", @@ -29,13 +29,17 @@ }, "scripts": { "cleanup": "rimraf dist", - "dev": "vite", + "dev": "tsdown --watch", "lint": "eslint src --quiet", "lint:fix": "eslint src --fix", - "lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter $(pwd)/../../../packages/@n8n/stylelint-config/dist/formatter-summary.js", - "lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter $(pwd)/../../../packages/@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles": "run-script-os", + "lint:styles:default": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter $(pwd)/../../../packages/@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles:windows": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter \"%cd%/../../../packages/@n8n/stylelint-config/dist/formatter-summary.js\"", + "lint:styles:fix": "run-script-os", + "lint:styles:fix:default": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter $(pwd)/../../../packages/@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles:fix:windows": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter \"%cd%/../../../packages/@n8n/stylelint-config/dist/formatter-summary.js\"", "typecheck": "vue-tsc --noEmit", - "build:backend": "tsup", + "build:backend": "tsdown", "build:frontend": "vite build", "build": "pnpm cleanup && pnpm run \"/^build:.*/\"", "preview": "vite preview" @@ -48,10 +52,12 @@ "@n8n/extension-sdk": "workspace:*" }, "devDependencies": { + "run-script-os": "catalog:", "@n8n/stylelint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@vitejs/plugin-vue": "catalog:frontend", "@vue/tsconfig": "catalog:frontend", + "tsdown": "catalog:", "rimraf": "catalog:", "vite": "catalog:", "vue": "catalog:frontend", diff --git a/packages/extensions/insights/tsconfig.vite.json b/packages/extensions/insights/tsconfig.vite.json index 624d886c18b..d9507b0ff1f 100644 --- a/packages/extensions/insights/tsconfig.vite.json +++ b/packages/extensions/insights/tsconfig.vite.json @@ -7,7 +7,6 @@ "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "strict": true, @@ -16,5 +15,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "tsdown.config.ts"] } diff --git a/packages/extensions/insights/tsup.config.ts b/packages/extensions/insights/tsdown.config.ts similarity index 87% rename from packages/extensions/insights/tsup.config.ts rename to packages/extensions/insights/tsdown.config.ts index c29e1bdbb41..a1380c759e9 100644 --- a/packages/extensions/insights/tsup.config.ts +++ b/packages/extensions/insights/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig({ entry: [ diff --git a/packages/frontend/@n8n/chat/package.json b/packages/frontend/@n8n/chat/package.json index 0a036e964ef..fdb8b04b730 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.60.0", + "version": "0.61.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", @@ -12,8 +12,8 @@ "typecheck": "vue-tsc --noEmit", "lint": "eslint src --quiet", "lint:fix": "eslint src --fix", - "lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter ../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js", - "lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter $(pwd)/../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache", + "lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache", "format": "biome format --write src .storybook && prettier --write src/ --ignore-path ../../../../.prettierignore", "format:check": "biome ci src .storybook && prettier --check src/ --ignore-path ../../../../.prettierignore", "storybook": "storybook dev -p 6006 --no-open", diff --git a/packages/frontend/@n8n/chat/src/components/MessagesList.vue b/packages/frontend/@n8n/chat/src/components/MessagesList.vue index 0811378cb4b..1e1677bfd8f 100644 --- a/packages/frontend/@n8n/chat/src/components/MessagesList.vue +++ b/packages/frontend/@n8n/chat/src/components/MessagesList.vue @@ -87,9 +87,9 @@ watch( flex-direction: column; align-items: center; justify-content: center; - gap: var(--spacing-xs); - padding-inline: var(--spacing-m); - padding-bottom: var(--spacing-l); + gap: var(--spacing--xs); + padding-inline: var(--spacing--md); + padding-bottom: var(--spacing--lg); overflow: hidden; } diff --git a/packages/frontend/@n8n/chat/src/css/markdown.scss b/packages/frontend/@n8n/chat/src/css/markdown.scss index f8b980c536a..5a33d03caa3 100644 --- a/packages/frontend/@n8n/chat/src/css/markdown.scss +++ b/packages/frontend/@n8n/chat/src/css/markdown.scss @@ -122,7 +122,7 @@ body { kbd, samp { font-family: - var(--font-family-monospace), + var(--font-family--monospace), /* macOS emoji */ 'Apple Color Emoji', /* Windows emoji */ 'Segoe UI Emoji', /* Windows emoji */ 'Segoe UI Symbol', @@ -147,7 +147,7 @@ body { */ b, strong { - font-weight: var(--font-weight-bold); + font-weight: var(--font-weight--bold); } /* @@ -461,7 +461,7 @@ body { display: block; font-size: 1em; font-style: italic; - font-weight: var(--font-weight-regular); + font-weight: var(--font-weight--regular); line-height: 1.3; margin-top: 0.3em; } @@ -501,7 +501,7 @@ body { */ ins { text-decoration: none; - font-weight: var(--font-weight-bold); + font-weight: var(--font-weight--bold); } blockquote { diff --git a/packages/frontend/@n8n/chat/stylelint.config.mjs b/packages/frontend/@n8n/chat/stylelint.config.mjs index a86e148c672..13a9b07ab57 100644 --- a/packages/frontend/@n8n/chat/stylelint.config.mjs +++ b/packages/frontend/@n8n/chat/stylelint.config.mjs @@ -1,3 +1,13 @@ import { baseConfig } from '@n8n/stylelint-config/base'; -export default baseConfig; +export default { + ...baseConfig, + rules: { + ...baseConfig.rules, + // Disable css-var-naming rule for chat package + // Because most var names seem to be breaking + // And it needs to continue to be backwards compatible + // As users could be using it directly on websites and customizing css variables + '@n8n/css-var-naming': null, + }, +}; diff --git a/packages/frontend/@n8n/composables/package.json b/packages/frontend/@n8n/composables/package.json index 587d4734348..709b9e7e1eb 100644 --- a/packages/frontend/@n8n/composables/package.json +++ b/packages/frontend/@n8n/composables/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/composables", "type": "module", - "version": "1.10.0", + "version": "1.11.0", "files": [ "dist" ], @@ -13,8 +13,8 @@ } }, "scripts": { - "dev": "vite", - "build": "tsup", + "dev": "tsdown --watch", + "build": "tsdown", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", "test": "vitest run", @@ -35,7 +35,7 @@ "@vue/tsconfig": "catalog:frontend", "@vueuse/core": "catalog:frontend", "vue": "catalog:frontend", - "tsup": "catalog:", + "tsdown": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/packages/frontend/@n8n/composables/tsconfig.json b/packages/frontend/@n8n/composables/tsconfig.json index 10f9ee1b738..61cbaa9b324 100644 --- a/packages/frontend/@n8n/composables/tsconfig.json +++ b/packages/frontend/@n8n/composables/tsconfig.json @@ -7,5 +7,5 @@ "types": ["vite/client", "vitest/globals"], "isolatedModules": true }, - "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsup.config.ts"] + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsdown.config.ts"] } diff --git a/packages/frontend/@n8n/composables/tsup.config.ts b/packages/frontend/@n8n/composables/tsdown.config.ts similarity index 67% rename from packages/frontend/@n8n/composables/tsup.config.ts rename to packages/frontend/@n8n/composables/tsdown.config.ts index bff21e25504..949899b661e 100644 --- a/packages/frontend/@n8n/composables/tsup.config.ts +++ b/packages/frontend/@n8n/composables/tsdown.config.ts @@ -1,11 +1,10 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; +// eslint-disable-next-line import-x/no-default-export export default defineConfig({ entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'], format: ['cjs', 'esm'], clean: true, dts: true, - cjsInterop: true, - splitting: true, sourcemap: true, }); diff --git a/packages/frontend/@n8n/design-system/.storybook/preview.ts b/packages/frontend/@n8n/design-system/.storybook/preview.ts index 577e51127a1..4326f01f6a5 100644 --- a/packages/frontend/@n8n/design-system/.storybook/preview.ts +++ b/packages/frontend/@n8n/design-system/.storybook/preview.ts @@ -1,5 +1,3 @@ -import { library } from '@fortawesome/fontawesome-svg-core'; -import { fas } from '@fortawesome/free-solid-svg-icons'; import { sharedTags } from '@n8n/storybook/main'; import { withThemeByDataAttribute } from '@storybook/addon-themes'; import { setup } from '@storybook/vue3'; @@ -13,8 +11,6 @@ import './storybook.scss'; // import '../src/css/tailwind/index.css'; setup((app) => { - library.add(fas); - app.use(ElementPlus, { locale: lang, }); diff --git a/packages/frontend/@n8n/design-system/.storybook/storybook.scss b/packages/frontend/@n8n/design-system/.storybook/storybook.scss index 7ef6e515d84..f7c714bb5e1 100644 --- a/packages/frontend/@n8n/design-system/.storybook/storybook.scss +++ b/packages/frontend/@n8n/design-system/.storybook/storybook.scss @@ -9,7 +9,7 @@ #storybook-root > div:not([class]) > *, #storybook-root > * { - margin: var(--spacing-5xs); + margin: var(--spacing--5xs); } body { diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 99b5f8f2f8a..24705094d24 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@n8n/design-system", - "version": "1.102.0", + "version": "1.103.0", "main": "src/index.ts", "import": "src/index.ts", "scripts": { @@ -19,8 +19,12 @@ "format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore", "lint": "eslint src --quiet", "lint:fix": "eslint src --fix", - "lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter \"$(pwd)/../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js\"", - "lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter $(pwd)/../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js" + "lint:styles": "run-script-os", + "lint:styles:default": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter \"$(pwd)/../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js\"", + "lint:styles:windows": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter \"%cd%/../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js\"", + "lint:styles:fix": "run-script-os", + "lint:styles:fix:default": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter $(pwd)/../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles:fix:windows": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter \"%cd%/../../../../packages/@n8n/stylelint-config/dist/formatter-summary.js\"" }, "devDependencies": { "@n8n/eslint-config": "workspace:*", @@ -47,12 +51,10 @@ "vitest": "catalog:", "vitest-mock-extended": "catalog:", "vue-tsc": "catalog:frontend", - "@vue/test-utils": "catalog:frontend" + "@vue/test-utils": "catalog:frontend", + "run-script-os": "catalog:" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "^1.2.36", - "@fortawesome/free-solid-svg-icons": "^5.15.4", - "@fortawesome/vue-fontawesome": "^3.0.3", "@n8n/composables": "workspace:*", "@n8n/utils": "workspace:*", "@tanstack/vue-table": "^8.21.2", diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue index f930e5a674b..85b0ac0e523 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue @@ -14,7 +14,7 @@ withDefaults(defineProps<{ size?: 'small' | 'mini' }>(), { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/__snapshots__/AskAssistantAvatar.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/__snapshots__/AskAssistantAvatar.test.ts.snap index ba82ada82e2..2c246ed5ebe 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/__snapshots__/AskAssistantAvatar.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantAvatar/__snapshots__/AskAssistantAvatar.test.ts.snap @@ -26,15 +26,15 @@ exports[`AskAssistantAvatar > renders mini avatar correctly 1`] = ` y2="9.82667" > @@ -69,15 +69,15 @@ exports[`AskAssistantAvatar > renders small avatar correctly 1`] = ` y2="9.82667" > diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantButton/AskAssistantButton.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantButton/AskAssistantButton.vue index 71aeac35523..3bdeeab35ff 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantButton/AskAssistantButton.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantButton/AskAssistantButton.vue @@ -53,9 +53,9 @@ function onMouseLeave() { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/BlockMessage.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/BlockMessage.vue index 5d3968a1b98..1f4a6056fbf 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/BlockMessage.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/BlockMessage.vue @@ -47,56 +47,56 @@ const { renderMarkdown } = useMarkdown(); diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/EventMessage.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/EventMessage.vue index f495f7e126d..1e5ea3dfe32 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/EventMessage.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/EventMessage.vue @@ -55,14 +55,14 @@ const eventMessages: Record = { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/AssistantIcon.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/AssistantIcon.vue index 66568c3c064..9013df9fdc6 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/AssistantIcon.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/AssistantIcon.vue @@ -50,9 +50,9 @@ const svgFill = computed(() => { y2="9.82667" gradientUnits="userSpaceOnUse" > - - - + + + diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/__snapshots__/AssistantIcon.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/__snapshots__/AssistantIcon.test.ts.snap index 6614b3d613a..93978e95cd1 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/__snapshots__/AssistantIcon.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantIcon/__snapshots__/AssistantIcon.test.ts.snap @@ -23,15 +23,15 @@ exports[`AssistantIcon > renders blank icon correctly 1`] = ` y2="9.82667" > @@ -62,15 +62,15 @@ exports[`AssistantIcon > renders default icon correctly 1`] = ` y2="9.82667" > @@ -101,15 +101,15 @@ exports[`AssistantIcon > renders large icon correctly 1`] = ` y2="9.82667" > @@ -140,15 +140,15 @@ exports[`AssistantIcon > renders medium icon correctly 1`] = ` y2="9.82667" > @@ -179,15 +179,15 @@ exports[`AssistantIcon > renders mini icon correctly 1`] = ` y2="9.82667" > @@ -218,15 +218,15 @@ exports[`AssistantIcon > renders small icon correctly 1`] = ` y2="9.82667" > diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/AssistantLoadingMessage.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/AssistantLoadingMessage.vue index f4b5bc57b9f..75db1f29183 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/AssistantLoadingMessage.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/AssistantLoadingMessage.vue @@ -30,7 +30,7 @@ withDefaults( .container { display: flex; align-items: center; - gap: var(--spacing-2xs); + gap: var(--spacing--2xs); user-select: none; } @@ -38,7 +38,7 @@ withDefaults( display: flex; align-items: center; justify-content: center; - width: var(--spacing-m); + width: var(--spacing--md); flex-shrink: 0; } diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/DemoComponent.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/DemoComponent.vue index 1660e139aa8..766a1c1de9a 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/DemoComponent.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantLoadingMessage/DemoComponent.vue @@ -51,6 +51,6 @@ onMounted(() => { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantText/AssistantText.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantText/AssistantText.vue index df941ef00ea..ddb656e4db6 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantText/AssistantText.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantText/AssistantText.vue @@ -11,21 +11,21 @@ withDefaults(defineProps<{ text: string; size?: 'small' | 'medium' | 'large' | ' diff --git a/packages/frontend/@n8n/design-system/src/components/BetaTag/BetaTag.vue b/packages/frontend/@n8n/design-system/src/components/BetaTag/BetaTag.vue index b93fad9634f..3f8c4a72a3a 100644 --- a/packages/frontend/@n8n/design-system/src/components/BetaTag/BetaTag.vue +++ b/packages/frontend/@n8n/design-system/src/components/BetaTag/BetaTag.vue @@ -13,10 +13,10 @@ const { t } = useI18n(); display: inline-block; color: var(--color--secondary); - font-size: var(--font-size-3xs); - font-weight: var(--font-weight-bold); + font-size: var(--font-size--3xs); + font-weight: var(--font-weight--bold); background-color: var(--color--secondary--tint-2); - padding: var(--spacing-5xs) var(--spacing-4xs); + padding: var(--spacing--5xs) var(--spacing--4xs); border-radius: 16px; } diff --git a/packages/frontend/@n8n/design-system/src/components/BlinkingCursor/BlinkingCursor.vue b/packages/frontend/@n8n/design-system/src/components/BlinkingCursor/BlinkingCursor.vue index c33862bae72..93954513981 100644 --- a/packages/frontend/@n8n/design-system/src/components/BlinkingCursor/BlinkingCursor.vue +++ b/packages/frontend/@n8n/design-system/src/components/BlinkingCursor/BlinkingCursor.vue @@ -5,10 +5,10 @@ diff --git a/packages/frontend/@n8n/design-system/src/components/CanvasThinkingPill/__snapshots__/CanvasThinkingPill.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/CanvasThinkingPill/__snapshots__/CanvasThinkingPill.test.ts.snap index 8072ee0ec8c..da20ff10706 100644 --- a/packages/frontend/@n8n/design-system/src/components/CanvasThinkingPill/__snapshots__/CanvasThinkingPill.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/CanvasThinkingPill/__snapshots__/CanvasThinkingPill.test.ts.snap @@ -29,15 +29,15 @@ exports[`N8nCanvasThinkingPill > renders canvas thinking pill correctly 1`] = ` y2="9.82667" > diff --git a/packages/frontend/@n8n/design-system/src/components/CodeDiff/CodeDiff.vue b/packages/frontend/@n8n/design-system/src/components/CodeDiff/CodeDiff.vue index 4ac842b88db..d25147caec7 100644 --- a/packages/frontend/@n8n/design-system/src/components/CodeDiff/CodeDiff.vue +++ b/packages/frontend/@n8n/design-system/src/components/CodeDiff/CodeDiff.vue @@ -143,15 +143,15 @@ const diffs = computed(() => { diff --git a/packages/frontend/@n8n/design-system/src/components/InlineAskAssistantButton/InlineAskAssistantButton.vue b/packages/frontend/@n8n/design-system/src/components/InlineAskAssistantButton/InlineAskAssistantButton.vue index 00c82c42de1..6cd0eae7007 100644 --- a/packages/frontend/@n8n/design-system/src/components/InlineAskAssistantButton/InlineAskAssistantButton.vue +++ b/packages/frontend/@n8n/design-system/src/components/InlineAskAssistantButton/InlineAskAssistantButton.vue @@ -63,15 +63,15 @@ const onClick = () => { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue index d8cfb96dc17..6f41a9e9560 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue @@ -75,14 +75,14 @@ withDefaults(defineProps(), { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nAlert/Alert.vue b/packages/frontend/@n8n/design-system/src/components/N8nAlert/Alert.vue index a98910d0b2f..1c9a62fe348 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nAlert/Alert.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nAlert/Alert.vue @@ -123,11 +123,11 @@ const alertBoxClassNames = computed(() => { &.info { &.light { - color: var(--color-info); + color: var(--color--info); &.background { background-color: var.$alert-info-color; - border-color: var(--color-info); + border-color: var(--color--info); } } @@ -135,17 +135,17 @@ const alertBoxClassNames = computed(() => { color: var.$color-white; &:not(.background) { - color: var(--color-info); + color: var(--color--info); } &.background { - background-color: var(--color-info); + background-color: var(--color--info); border-color: var.$color-white; } } .el-alert__description { - color: var(--color-info); + color: var(--color--info); } } @@ -215,8 +215,8 @@ const alertBoxClassNames = computed(() => { display: inline-flex; color: inherit; align-items: center; - padding-left: var(--spacing-2xs); - padding-right: var(--spacing-s); + padding-left: var(--spacing--2xs); + padding-right: var(--spacing--sm); } .text { @@ -228,7 +228,7 @@ const alertBoxClassNames = computed(() => { .title { font-size: var.$alert-title-font-size; line-height: 18px; - font-weight: var(--font-weight-bold); + font-weight: var(--font-weight--bold); } .description { @@ -242,6 +242,6 @@ const alertBoxClassNames = computed(() => { .aside { display: inline-flex; align-items: center; - padding-left: var(--spacing-s); + padding-left: var(--spacing--sm); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nAvatar/Avatar.vue b/packages/frontend/@n8n/design-system/src/components/N8nAvatar/Avatar.vue index dca21e71503..86fda5a9663 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nAvatar/Avatar.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nAvatar/Avatar.vue @@ -19,8 +19,8 @@ const props = withDefaults(defineProps(), { colors: () => [ '--color--primary', '--color--secondary', - '--color-avatar-accent-1', - '--color-avatar-accent-2', + '--avatar--color--accent-1', + '--avatar--color--accent-2', '--color--primary--tint-1', ], }); @@ -79,9 +79,9 @@ const getSize = (size: string): number => sizes[size]; .initials { position: absolute; - font-size: var(--font-size-2xs); - font-weight: var(--font-weight-bold); - color: var(--color-avatar-font); + font-size: var(--font-size--2xs); + font-weight: var(--font-weight--bold); + color: var(--avatar--color--text); text-shadow: 0 1px 6px rgba(25, 11, 9, 0.3); text-transform: uppercase; } @@ -91,8 +91,8 @@ const getSize = (size: string): number => sizes[size]; } .xsmall { - height: var(--spacing-m); - width: var(--spacing-m); + height: var(--spacing--md); + width: var(--spacing--md); } .small { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBadge/Badge.vue b/packages/frontend/@n8n/design-system/src/components/N8nBadge/Badge.vue index 2b27eb9fdf0..2082dcdb85d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBadge/Badge.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBadge/Badge.vue @@ -31,46 +31,46 @@ withDefaults(defineProps(), { .badge { display: inline-flex; align-items: center; - padding: var(--spacing-5xs) var(--spacing-4xs); + padding: var(--spacing--5xs) var(--spacing--4xs); white-space: nowrap; &.border { - border: var(--border-base); + border: var(--border); } } .default { composes: badge; - border-radius: var(--border-radius-base); + border-radius: var(--radius); color: var(--color--text--tint-1); border-color: var(--color--text--tint-1); } .success { composes: badge; - border-radius: var(--border-radius-base); + border-radius: var(--radius); color: var(--color--success); border-color: var(--color--success); } .warning { composes: badge; - border-radius: var(--border-radius-base); + border-radius: var(--radius); color: var(--color--warning); border-color: var(--color--warning); } .danger { composes: badge; - border-radius: var(--border-radius-base); + border-radius: var(--radius); color: var(--color--danger); border-color: var(--color--danger); } .primary { composes: badge; - padding: var(--spacing-5xs) var(--spacing-3xs); - border-radius: var(--border-radius-xlarge); + padding: var(--spacing--5xs) var(--spacing--3xs); + border-radius: var(--radius--xl); color: var(--color--foreground--tint-2); background-color: var(--color--primary); border-color: var(--color--primary); @@ -78,16 +78,16 @@ withDefaults(defineProps(), { .secondary { composes: badge; - border-radius: var(--border-radius-xlarge); + border-radius: var(--radius--xl); color: var(--color--secondary); background-color: var(--color--secondary--tint-1); } .tertiary { composes: badge; - border-radius: var(--border-radius-base); + border-radius: var(--radius); color: var(--color--text--tint-1); border-color: var(--color--text--tint-1); - padding: 1px var(--spacing-5xs); + padding: 1px var(--spacing--5xs); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBlockUi/BlockUi.vue b/packages/frontend/@n8n/design-system/src/components/N8nBlockUi/BlockUi.vue index dff5201a721..9feae1620e6 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBlockUi/BlockUi.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBlockUi/BlockUi.vue @@ -26,10 +26,10 @@ withDefaults(defineProps(), { left: 0; width: 100%; height: 100%; - background-color: var(--color-block-ui-overlay); + background-color: var(--block-ui--overlay--color); z-index: 10; opacity: 0.6; - border-radius: var(--border-radius-large); + border-radius: var(--radius--lg); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue index 67a43bfc86b..991f806d66e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue @@ -263,12 +263,12 @@ const handleTooltipClose = () => { &.small { display: inline-flex; - padding: var(--spacing-4xs) var(--spacing-3xs); + padding: var(--spacing--4xs) var(--spacing--3xs); } &.border { - border: var(--border-base); - border-radius: var(--border-radius-base); + border: var(--border); + border-radius: var(--radius); } } @@ -279,13 +279,13 @@ const handleTooltipClose = () => { } .item { - border: var(--border-width-base) var(--border-style-base) transparent; + border: var(--border-width) var(--border-style) transparent; } .item.dragging:hover { - border: var(--border-width-base) var(--border-style-base) var(--color--secondary); - border-radius: var(--border-radius-base); - background-color: var(--color-callout-secondary-background); + border: var(--border-width) var(--border-style) var(--color--secondary); + border-radius: var(--radius); + background-color: var(--callout--color--background--secondary); & a { cursor: grabbing; @@ -339,11 +339,11 @@ const handleTooltipClose = () => { &.dragging li:hover { cursor: grabbing; - background-color: var(--color-callout-secondary-background); + background-color: var(--callout--color--background--secondary); } li { - max-width: var(--spacing-5xl); + max-width: var(--spacing--5xl); display: block; white-space: nowrap; overflow: hidden; @@ -352,13 +352,13 @@ const handleTooltipClose = () => { } .tooltip-loading { - min-width: var(--spacing-3xl); + min-width: var(--spacing--3xl); width: 100%; :global(.n8n-loading) > div { display: flex; flex-direction: column; - gap: var(--spacing-xs); + gap: var(--spacing--xs); } :global(.el-skeleton__item) { @@ -367,24 +367,24 @@ const handleTooltipClose = () => { } .tooltip { - padding: var(--spacing-xs) var(--spacing-2xs); + padding: var(--spacing--xs) var(--spacing--2xs); text-align: center; & > div { color: var(--color--text--tint-2); span { - font-size: var(--font-size-2xs); + font-size: var(--font-size--2xs); } } .tooltip-loading { - min-width: var(--spacing-4xl); + min-width: var(--spacing--4xl); } } .dots { - padding: 0 var(--spacing-4xs); + padding: 0 var(--spacing--4xs); color: var(--color--text--tint-1); - border-radius: var(--border-radius-base); + border-radius: var(--radius); &:hover, &:focus { @@ -396,18 +396,18 @@ const handleTooltipClose = () => { // Small theme overrides .small { .list { - gap: var(--spacing-5xs); + gap: var(--spacing--5xs); } .item { - max-width: var(--spacing-3xl); + max-width: var(--spacing--3xl); } .item, .item * { color: var(--color--text); - font-size: var(--font-size-2xs); - line-height: var(--font-line-height-xsmall); + font-size: var(--font-size--2xs); + line-height: var(--line-height--xs); } .item a:hover * { @@ -415,7 +415,7 @@ const handleTooltipClose = () => { } .separator { - font-size: var(--font-size-s); + font-size: var(--font-size--sm); color: var(--color--text); } } @@ -423,18 +423,18 @@ const handleTooltipClose = () => { // Medium theme overrides .medium { li { - padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs); + padding: var(--spacing--3xs) var(--spacing--4xs) var(--spacing--4xs); } .item, .item * { color: var(--color--text); - font-size: var(--font-size-s); - line-height: var(--font-line-height-xsmall); + font-size: var(--font-size--sm); + line-height: var(--line-height--xs); } .item { - max-width: var(--spacing-5xl); + max-width: var(--spacing--5xl); } .item:not(.dragging) a:hover * { @@ -451,7 +451,7 @@ const handleTooltipClose = () => { } .separator { - font-size: var(--font-size-xl); + font-size: var(--font-size--xl); color: var(--color--foreground); } } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss index 33e44f3f987..1233e19d0c9 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss +++ b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss @@ -10,11 +10,11 @@ white-space: nowrap; cursor: pointer; - border: var(--border-width-base) var.$button-border-color var(--border-style-base) + border: var(--border-width) var.$button-border-color var(--border-style) string.unquote($important); color: var.$button-font-color string.unquote($important); background-color: var.$button-background-color string.unquote($important); - font-weight: var(--font-weight-medium) string.unquote($important); + font-weight: var(--font-weight--medium) string.unquote($important); border-radius: var.$button-border-radius string.unquote($important); padding: var.$button-padding-vertical var.$button-padding-horizontal string.unquote($important); font-size: var.$button-font-size string.unquote($important); @@ -30,7 +30,7 @@ width 0s, height 0s; - gap: var(--spacing-3xs); + gap: var(--spacing--3xs); @include utils.utils-user-select(none); @@ -119,136 +119,136 @@ } @mixin n8n-button-secondary { - --button-font-color: var(--color-button-secondary-font); - --button-border-color: var(--color-button-secondary-border); - --button-background-color: var(--color-button-secondary-background); + --button--color--text: var(--button--color--text--secondary); + --button--border-color: var(--button--border-color--secondary); + --button--color--background: var(--button--color--background--secondary); - --button-hover-font-color: var(--color-button-secondary-hover-active-focus-font); - --button-hover-border-color: var(--color-button-secondary-hover-active-focus-border); - --button-hover-background-color: var(--color-button-secondary-hover-background); + --button--color--text--hover: var(--button--color--text--secondary--hover-active-focus); + --button--border-color--hover: var(--button--border-color--secondary--hover-active-focus); + --button--color--background--hover: var(--button--color--background--secondary--hover); - --button-active-font-color: var(--color-button-secondary-hover-active-focus-font); - --button-active-border-color: var(--color-button-secondary-hover-active-focus-border); - --button-active-background-color: var(--color-button-secondary-active-focus-background); + --button--color--text--active: var(--button--color--text--secondary--hover-active-focus); + --button--border-color--active: var(--button--border-color--secondary--hover-active-focus); + --button--color--background--active: var(--button--color--background--secondary--active-focus); - --button-focus-font-color: var(--color-button-secondary-hover-active-focus-font); - --button-focus-border-color: var(--color-button-secondary-hover-active-focus-border); - --button-focus-background-color: var(--color-button-secondary-active-focus-background); - --button-focus-outline-color: var(--color-button-secondary-focus-outline); + --button--color--text--focus: var(--button--color--text--secondary--hover-active-focus); + --button--border-color--focus: var(--button--border-color--secondary--hover-active-focus); + --button--color--background--focus: var(--button--color--background--secondary--active-focus); + --button--outline-color--focus: var(--button--outline-color--secondary--focus); - --button-disabled-font-color: var(--color-button-secondary-disabled-font); - --button-disabled-border-color: var(--color-button-secondary-disabled-border); - --button-disabled-background-color: var(--color-button-secondary-background); + --button--color--text--disabled: var(--button--color--text--secondary--disabled); + --button--border-color--disabled: var(--button--border-color--secondary--disabled); + --button--color--background--disabled: var(--button--color--background--secondary); - --button-loading-font-color: var(--color-button-secondary-loading-font); - --button-loading-border-color: var(--color-button-secondary-loading-border); - --button-loading-background-color: var(--color-button-secondary-loading-background); + --button--color--text--loading: var(--button--color--text--secondary--loading); + --button--border-color--loading: var(--button--border-color--secondary--loading); + --button--color--background--loading: var(--button--color--background--secondary--loading); } @mixin n8n-button-highlight { - --button-font-color: var(--color-button-highlight-font); - --button-border-color: var(--color-button-highlight-border); - --button-background-color: var(--color-button-highlight-background); + --button--color--text: var(--button--color--text--highlight); + --button--border-color: var(--button--border-color--highlight); + --button--color--background: var(--button--color--background--highlight); - --button-hover-font-color: var(--color-button-highlight-hover-active-focus-font); - --button-hover-border-color: var(--color-button-highlight-hover-active-focus-border); - --button-hover-background-color: var(--color-button-highlight-hover-background); + --button--color--text--hover: var(--button--color--text--highlight--hover-active-focus); + --button--border-color--hover: var(--button--border-color--highlight--hover-active-focus); + --button--color--background--hover: var(--button--color--background--highlight--hover); - --button-active-font-color: var(--color-button-highlight-hover-active-focus-font); - --button-active-border-color: var(--color-button-highlight-hover-active-focus-border); - --button-active-background-color: var(--color-button-highlight-active-focus-background); + --button--color--text--active: var(--button--color--text--highlight--hover-active-focus); + --button--border-color--active: var(--button--border-color--highlight--hover-active-focus); + --button--color--background--active: var(--button--color--background--highlight--active-focus); - --button-focus-font-color: var(--color-button-highlight-hover-active-focus-font); - --button-focus-border-color: var(--color-button-highlight-hover-active-focus-border); - --button-focus-background-color: var(--color-button-highlight-active-focus-background); - --button-focus-outline-color: var(--color-button-highlight-focus-outline); + --button--color--text--focus: var(--button--color--text--highlight--hover-active-focus); + --button--border-color--focus: var(--button--border-color--highlight--hover-active-focus); + --button--color--background--focus: var(--button--color--background--highlight--active-focus); + --button--outline-color--focus: var(--button--outline-color--highlight--focus); - --button-disabled-font-color: var(--color-button-highlight-disabled-font); - --button-disabled-border-color: var(--color-button-highlight-disabled-border); - --button-disabled-background-color: var(--color-button-highlight-disabled-background); + --button--color--text--disabled: var(--button--color--text--highlight--disabled); + --button--border-color--disabled: var(--button--border-color--highlight--disabled); + --button--color--background--disabled: var(--button--color--background--highlight--disabled); - --button-loading-font-color: var(--color-button-highlight-loading-font); - --button-loading-border-color: var(--color-button-highlight-loading-border); - --button-loading-background-color: var(--color-button-highlight-loading-background); + --button--color--text--loading: var(--button--color--text--highlight--loading); + --button--border-color--loading: var(--button--border-color--highlight--loading); + --button--color--background--loading: var(--button--color--background--highlight--loading); } @mixin n8n-button-success { - --button-font-color: var(--color-button-success-font); - --button-border-color: var(--color--success); - --button-background-color: var(--color--success); + --button--color--text: var(--button--color--text--success); + --button--border-color: var(--color--success); + --button--color--background: var(--color--success); - --button-hover-font-color: var(--color-button-success-font); - --button-hover-border-color: var(--color--success--shade-1); - --button-hover-background-color: var(--color--success--shade-1); + --button--color--text--hover: var(--button--color--text--success); + --button--border-color--hover: var(--color--success--shade-1); + --button--color--background--hover: var(--color--success--shade-1); - --button-active-font-color: var(--color-button-success-font); - --button-active-border-color: var(--color--success--shade-1); - --button-active-background-color: var(--color--success--shade-1); + --button--color--text--active: var(--button--color--text--success); + --button--border-color--active: var(--color--success--shade-1); + --button--color--background--active: var(--color--success--shade-1); - --button-focus-font-color: var(--color-button-success-font); - --button-focus-border-color: var(--color--success); - --button-focus-background-color: var(--color--success); - --button-focus-outline-color: var(--color--success--tint-1); + --button--color--text--focus: var(--button--color--text--success); + --button--border-color--focus: var(--color--success); + --button--color--background--focus: var(--color--success); + --button--outline-color--focus: var(--color--success--tint-1); - --button-disabled-font-color: var(--color-button-success-disabled-font); - --button-disabled-border-color: var(--color--success--tint-3); - --button-disabled-background-color: var(--color--success--tint-3); + --button--color--text--disabled: var(--button--color--text--success--disabled); + --button--border-color--disabled: var(--color--success--tint-3); + --button--color--background--disabled: var(--color--success--tint-3); - --button-loading-font-color: var(--color-button-success-font); - --button-loading-border-color: var(--color--success); - --button-loading-background-color: var(--color--success); + --button--color--text--loading: var(--button--color--text--success); + --button--border-color--loading: var(--color--success); + --button--color--background--loading: var(--color--success); } @mixin n8n-button-warning { - --button-font-color: var(--color-button-warning-font); - --button-border-color: var(--color--warning); - --button-background-color: var(--color--warning); + --button--color--text: var(--button--color--text--warning); + --button--border-color: var(--color--warning); + --button--color--background: var(--color--warning); - --button-hover-font-color: var(--color-button-warning-font); - --button-hover-border-color: var(--color--warning--shade-1); - --button-hover-background-color: var(--color--warning--shade-1); + --button--color--text--hover: var(--button--color--text--warning); + --button--border-color--hover: var(--color--warning--shade-1); + --button--color--background--hover: var(--color--warning--shade-1); - --button-active-font-color: var(--color-button-warning-font); - --button-active-border-color: var(--color--warning--shade-1); - --button-active-background-color: var(--color--warning--shade-1); + --button--color--text--active: var(--button--color--text--warning); + --button--border-color--active: var(--color--warning--shade-1); + --button--color--background--active: var(--color--warning--shade-1); - --button-focus-font-color: var(--color-button-warning-font); - --button-focus-border-color: var(--color--warning); - --button-focus-background-color: var(--color--warning); - --button-focus-outline-color: var(--color--warning--tint-1); + --button--color--text--focus: var(--button--color--text--warning); + --button--border-color--focus: var(--color--warning); + --button--color--background--focus: var(--color--warning); + --button--outline-color--focus: var(--color--warning--tint-1); - --button-disabled-font-color: var(--color-button-warning-disabled-font); - --button-disabled-border-color: var(--color--warning--tint-1); - --button-disabled-background-color: var(--color--warning--tint-1); + --button--color--text--disabled: var(--button--color--text--warning--disabled); + --button--border-color--disabled: var(--color--warning--tint-1); + --button--color--background--disabled: var(--color--warning--tint-1); - --button-loading-font-color: var(--color-button-warning-font); - --button-loading-border-color: var(--color--warning); - --button-loading-background-color: var(--color--warning); + --button--color--text--loading: var(--button--color--text--warning); + --button--border-color--loading: var(--color--warning); + --button--color--background--loading: var(--color--warning); } @mixin n8n-button-danger { - --button-font-color: var(--color-button-danger-font); - --button-border-color: var(--color-button-danger-border); - --button-background-color: var(--color--danger); + --button--color--text: var(--button--color--text--danger); + --button--border-color: var(--button--border-color--danger); + --button--color--background: var(--color--danger); - --button-hover-font-color: var(--color-button-danger-font); - --button-hover-border-color: var(--color--danger--shade-1); - --button-hover-background-color: var(--color--danger--shade-1); + --button--color--text--hover: var(--button--color--text--danger); + --button--border-color--hover: var(--color--danger--shade-1); + --button--color--background--hover: var(--color--danger--shade-1); - --button-active-font-color: var(--color-button-danger-font); - --button-active-border-color: var(--color--danger--shade-1); - --button-active-background-color: var(--color--danger--shade-1); + --button--color--text--active: var(--button--color--text--danger); + --button--border-color--active: var(--color--danger--shade-1); + --button--color--background--active: var(--color--danger--shade-1); - --button-focus-font-color: var(--color-button-danger-font); - --button-focus-border-color: var(--color--danger); - --button-focus-background-color: var(--color--danger); - --button-focus-outline-color: var(--color-button-danger-focus-outline); + --button--color--text--focus: var(--button--color--text--danger); + --button--border-color--focus: var(--color--danger); + --button--color--background--focus: var(--color--danger); + --button--outline-color--focus: var(--button--outline-color--danger--focus); - --button-disabled-font-color: var(--color-button-danger-disabled-font); - --button-disabled-border-color: var(--color-button-danger-disabled-border); - --button-disabled-background-color: var(--color-button-danger-disabled-background); + --button--color--text--disabled: var(--button--color--text--danger--disabled); + --button--border-color--disabled: var(--button--border-color--danger--disabled); + --button--color--background--disabled: var(--button--color--background--danger--disabled); - --button-loading-font-color: var(--color-button-danger-font); - --button-loading-border-color: var(--color--danger); - --button-loading-background-color: var(--color--danger); + --button--color--text--loading: var(--button--color--text--danger); + --button--border-color--loading: var(--color--danger); + --button--color--background--loading: var(--color--danger); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue index 89cc3d40594..332d05ac1d7 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue @@ -85,12 +85,12 @@ const classes = computed(() => { .el-button { @include Button.n8n-button(true); - --button-padding-vertical: var(--spacing-2xs); - --button-padding-horizontal: var(--spacing-xs); - --button-font-size: var(--font-size-2xs); + --button--padding--vertical: var(--spacing--2xs); + --button--padding--horizontal: var(--spacing--xs); + --button--font-size: var(--font-size--2xs); + .el-button { - margin-left: var(--spacing-2xs); + margin-left: var(--spacing--2xs); } &.btn--cancel, @@ -144,9 +144,9 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); */ .xmini { - --button-padding-vertical: var(--spacing-4xs); - --button-padding-horizontal: var(--spacing-3xs); - --button-font-size: var(--font-size-3xs); + --button--padding--vertical: var(--spacing--4xs); + --button--padding--horizontal: var(--spacing--3xs); + --button--font-size: var(--font-size--3xs); &.square { height: 22px; @@ -155,9 +155,9 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); } .mini { - --button-padding-vertical: var(--spacing-4xs); - --button-padding-horizontal: var(--spacing-2xs); - --button-font-size: var(--font-size-2xs); + --button--padding--vertical: var(--spacing--4xs); + --button--padding--horizontal: var(--spacing--2xs); + --button--font-size: var(--font-size--2xs); &.square { height: 22px; @@ -166,9 +166,9 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); } .small { - --button-padding-vertical: var(--spacing-3xs); - --button-padding-horizontal: var(--spacing-xs); - --button-font-size: var(--font-size-2xs); + --button--padding--vertical: var(--spacing--3xs); + --button--padding--horizontal: var(--spacing--xs); + --button--font-size: var(--font-size--2xs); &.square { height: 26px; @@ -177,9 +177,9 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); } .medium { - --button-padding-vertical: var(--spacing-2xs); - --button-padding-horizontal: var(--spacing-xs); - --button-font-size: var(--font-size-2xs); + --button--padding--vertical: var(--spacing--2xs); + --button--padding--horizontal: var(--spacing--xs); + --button--font-size: var(--font-size--2xs); &.square { height: 30px; @@ -195,9 +195,9 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); } .xlarge { - --button-padding-vertical: var(--spacing-xs); - --button-padding-horizontal: var(--spacing-s); - --button-font-size: var(--font-size-m); + --button--padding--vertical: var(--spacing--xs); + --button--padding--horizontal: var(--spacing--sm); + --button--font-size: var(--font-size--md); &.square { height: 46px; @@ -209,97 +209,97 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); * Modifiers */ .outline { - --button-background-color: transparent; - --button-disabled-background-color: transparent; + --button--color--background: transparent; + --button--color--background--disabled: transparent; &.primary { - --button-font-color: var(--color--primary); - --button-disabled-font-color: var(--color--primary--tint-1); - --button-disabled-border-color: var(--color--primary--tint-1); - --button-disabled-background-color: transparent; + --button--color--text: var(--color--primary); + --button--color--text--disabled: var(--color--primary--tint-1); + --button--border-color--disabled: var(--color--primary--tint-1); + --button--color--background--disabled: transparent; } &.success { - --button-font-color: var(--color--success); - --button-border-color: var(--color--success); - --button-hover-border-color: var(--color--success); - --button-hover-background-color: var(--color--success); - --button-active-background-color: var(--color--success); - --button-disabled-font-color: var(--color--success--tint-1); - --button-disabled-border-color: var(--color--success--tint-1); - --button-disabled-background-color: transparent; + --button--color--text: var(--color--success); + --button--border-color: var(--color--success); + --button--border-color--hover: var(--color--success); + --button--color--background--hover: var(--color--success); + --button--color--background--active: var(--color--success); + --button--color--text--disabled: var(--color--success--tint-1); + --button--border-color--disabled: var(--color--success--tint-1); + --button--color--background--disabled: transparent; } &.warning { - --button-font-color: var(--color--warning); - --button-border-color: var(--color--warning); - --button-hover-border-color: var(--color--warning); - --button-hover-background-color: var(--color--warning); - --button-active-background-color: var(--color--warning); - --button-disabled-font-color: var(--color--warning--tint-1); - --button-disabled-border-color: var(--color--warning--tint-1); - --button-disabled-background-color: transparent; + --button--color--text: var(--color--warning); + --button--border-color: var(--color--warning); + --button--border-color--hover: var(--color--warning); + --button--color--background--hover: var(--color--warning); + --button--color--background--active: var(--color--warning); + --button--color--text--disabled: var(--color--warning--tint-1); + --button--border-color--disabled: var(--color--warning--tint-1); + --button--color--background--disabled: transparent; } &.danger { - --button-font-color: var(--color--danger); - --button-border-color: var(--color--danger); - --button-hover-border-color: var(--color--danger); - --button-hover-background-color: var(--color--danger); - --button-active-background-color: var(--color--danger); - --button-disabled-font-color: var(--color--danger--tint-3); - --button-disabled-border-color: var(--color--danger--tint-3); - --button-disabled-background-color: transparent; + --button--color--text: var(--color--danger); + --button--border-color: var(--color--danger); + --button--border-color--hover: var(--color--danger); + --button--color--background--hover: var(--color--danger); + --button--color--background--active: var(--color--danger); + --button--color--text--disabled: var(--color--danger--tint-3); + --button--border-color--disabled: var(--color--danger--tint-3); + --button--color--background--disabled: transparent; } } .text { - --button-font-color: var(--color-text-button-secondary-font); - --button-border-color: transparent; - --button-background-color: transparent; - --button-hover-border-color: transparent; - --button-hover-background-color: transparent; - --button-active-border-color: transparent; - --button-active-background-color: transparent; - --button-focus-border-color: transparent; - --button-focus-background-color: transparent; - --button-disabled-border-color: transparent; - --button-disabled-background-color: transparent; + --button--color--text: var(--text-button--color--text--secondary); + --button--border-color: transparent; + --button--color--background: transparent; + --button--border-color--hover: transparent; + --button--color--background--hover: transparent; + --button--border-color--active: transparent; + --button--color--background--active: transparent; + --button--border-color--focus: transparent; + --button--color--background--focus: transparent; + --button--border-color--disabled: transparent; + --button--color--background--disabled: transparent; &:focus { outline: 0; } &.primary { - --button-font-color: var(--color--primary); - --button-hover-font-color: var(--color--primary--shade-1); - --button-active-font-color: var(--color--primary--shade-1); - --button-focus-font-color: var(--color--primary); - --button-disabled-font-color: var(--color--primary--tint-1); + --button--color--text: var(--color--primary); + --button--color--text--hover: var(--color--primary--shade-1); + --button--color--text--active: var(--color--primary--shade-1); + --button--color--text--focus: var(--color--primary); + --button--color--text--disabled: var(--color--primary--tint-1); } &.success { - --button-font-color: var(--color--success); - --button-hover-font-color: var(--color--success--shade-1); - --button-active-font-color: var(--color--success--shade-1); - --button-focus-font-color: var(--color--success); - --button-disabled-font-color: var(--color--success--tint-1); + --button--color--text: var(--color--success); + --button--color--text--hover: var(--color--success--shade-1); + --button--color--text--active: var(--color--success--shade-1); + --button--color--text--focus: var(--color--success); + --button--color--text--disabled: var(--color--success--tint-1); } &.warning { - --button-font-color: var(--color--warning); - --button-hover-font-color: var(--color--warning--shade-1); - --button-active-font-color: var(--color--warning--shade-1); - --button-focus-font-color: var(--color--warning); - --button-disabled-font-color: var(--color--warning--tint-1); + --button--color--text: var(--color--warning); + --button--color--text--hover: var(--color--warning--shade-1); + --button--color--text--active: var(--color--warning--shade-1); + --button--color--text--focus: var(--color--warning); + --button--color--text--disabled: var(--color--warning--tint-1); } &.danger { - --button-font-color: var(--color--danger); - --button-hover-font-color: var(--color--danger--shade-1); - --button-active-font-color: var(--color--danger--shade-1); - --button-focus-font-color: var(--color--danger); - --button-disabled-font-color: var(--color--danger--tint-3); + --button--color--text: var(--color--danger); + --button--color--text--hover: var(--color--danger--shade-1); + --button--color--text--active: var(--color--danger--shade-1); + --button--color--text--focus: var(--color--danger); + --button--color--text--disabled: var(--color--danger--tint-3); } &:hover { @@ -334,8 +334,8 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); } .transparent { - --button-background-color: transparent; - --button-active-background-color: transparent; + --button--color--background: transparent; + --button--color--background--active: transparent; } .withIcon { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue b/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue index c63106e521d..a839b98171a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCallout/Callout.vue @@ -75,23 +75,23 @@ const getIconSize = computed(() => { .callout { display: flex; justify-content: space-between; - font-size: var(--font-size-2xs); - padding: var(--spacing-xs); - border: var(--border-width-base) var(--border-style-base); + font-size: var(--font-size--2xs); + padding: var(--spacing--xs); + border: var(--border-width) var(--border-style); align-items: center; - line-height: var(--font-line-height-xloose); - border-color: var(--color-callout-info-border); - background-color: var(--color-callout-info-background); - color: var(--color-callout-info-font); + line-height: var(--line-height--xl); + border-color: var(--callout--border-color--info); + background-color: var(--callout--color--background--info); + color: var(--callout--color--text--info); &.slim { - line-height: var(--font-line-height-xloose); - padding: var(--spacing-3xs) var(--spacing-2xs); + line-height: var(--line-height--xl); + padding: var(--spacing--3xs) var(--spacing--2xs); } a { - color: var(--color-secondary-link); - font-weight: var(--font-weight-medium); + color: var(--link--color--secondary); + font-weight: var(--font-weight--medium); text-decoration-line: underline; text-decoration-style: solid; text-decoration-skip-ink: none; @@ -102,7 +102,7 @@ const getIconSize = computed(() => { } .round { - border-radius: var(--border-radius-base); + border-radius: var(--radius); } .onlyBottomBorder { @@ -118,59 +118,59 @@ const getIconSize = computed(() => { .info, .custom { - border-color: var(--color-callout-info-border); - background-color: var(--color-callout-info-background); - color: var(--color-callout-info-font); + border-color: var(--callout--border-color--info); + background-color: var(--callout--color--background--info); + color: var(--callout--color--text--info); .icon { - color: var(--color-callout-info-icon); + color: var(--callout--icon-color--info); } } .success { - border-color: var(--color-callout-success-border); - background-color: var(--color-callout-success-background); - color: var(--color-callout-success-font); + border-color: var(--callout--border-color--success); + background-color: var(--callout--color--background--success); + color: var(--callout--color--text--success); .icon { - color: var(--color-callout-success-icon); + color: var(--callout--icon-color--success); } } .warning { - border-color: var(--color-callout-warning-border); - background-color: var(--color-callout-warning-background); - color: var(--color-callout-warning-font); + border-color: var(--callout--border-color--warning); + background-color: var(--callout--color--background--warning); + color: var(--callout--color--text--warning); .icon { - color: var(--color-callout-warning-icon); + color: var(--callout--icon-color--warning); } } .danger { - border-color: var(--color-callout-danger-border); - background-color: var(--color-callout-danger-background); - color: var(--color-callout-danger-font); + border-color: var(--callout--border-color--danger); + background-color: var(--callout--color--background--danger); + color: var(--callout--color--text--danger); .icon { - color: var(--color-callout-danger-icon); + color: var(--callout--icon-color--danger); } } .icon { line-height: 1; - margin-right: var(--spacing-xs); + margin-right: var(--spacing--xs); } .secondary { - font-size: var(--font-size-2xs); - font-weight: var(--font-weight-bold); - border-color: var(--color-callout-secondary-border); - background-color: var(--color-callout-secondary-background); - color: var(--color-callout-secondary-font); + font-size: var(--font-size--2xs); + font-weight: var(--font-weight--bold); + border-color: var(--callout--border-color--secondary); + background-color: var(--callout--color--background--secondary); + color: var(--callout--color--text--secondary); .icon { - color: var(--color-callout-secondary-icon); + color: var(--callout--icon-color--secondary); } } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCard/Card.vue b/packages/frontend/@n8n/design-system/src/components/N8nCard/Card.vue index 59fe8440d77..453c57d41d1 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCard/Card.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCard/Card.vue @@ -46,10 +46,10 @@ const classes = computed(() => ({ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts index 724e9dd7811..3137995e085 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts @@ -113,6 +113,9 @@ export default { items: { control: 'object', }, + isLoading: { + control: 'boolean', + }, }, parameters: { backgrounds: { default: '--color--background--light-2' }, @@ -126,11 +129,10 @@ const Template: StoryFn = (args, { argTypes }) => ({ N8nCommandBar, }, template: - '', + '', methods: { onInputChange: action('input-change'), onNavigateTo: action('navigate-to'), - onLoadMore: action('load-more'), }, }); @@ -178,14 +180,12 @@ export const KeyboardShortcut: StoryFn = () => ({ :items="items" @input-change="onInputChange" @navigate-to="onNavigateTo" - @load-more="onLoadMore" /> `, methods: { onInputChange: action('input-change'), onNavigateTo: action('navigate-to'), - onLoadMore: action('load-more'), }, }); @@ -208,13 +208,35 @@ export const SectionGrouping: StoryFn = () => ({ :items="items" @input-change="onInputChange" @navigate-to="onNavigateTo" - @load-more="onLoadMore" /> `, methods: { onInputChange: action('input-change'), onNavigateTo: action('navigate-to'), - onLoadMore: action('load-more'), + }, +}); + +export const Loading: StoryFn = () => ({ + components: { + N8nCommandBar, + }, + template: ` +
+

+ This example shows the loading state with skeleton loaders. +

+ +
+ `, + methods: { + onInputChange: action('input-change'), + onNavigateTo: action('navigate-to'), }, }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts new file mode 100644 index 00000000000..40137e32dd6 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts @@ -0,0 +1,143 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/vue'; +import { nextTick } from 'vue'; + +import N8nCommandBar from './CommandBar.vue'; + +const createSampleItems = () => [ + { id: 'recent-1', title: 'Recent: Customer Sync', icon: { html: '📋' } }, + { id: 'recent-2', title: 'Recent: Email Campaign', icon: { html: '✉️' } }, + + { + id: 'create', + title: 'Create new workflow', + icon: { html: '⚡' }, + section: 'Actions', + }, + { + id: 'import', + title: 'Import workflow', + icon: { html: '📥' }, + section: 'Actions', + keywords: ['upload', 'file'], + }, + + { id: 'workflows', title: 'All Workflows', icon: { html: '📁' }, section: 'Navigation' }, + { id: 'executions', title: 'Executions', icon: { html: '🏃' }, section: 'Navigation' }, + + { + id: 'search-nodes', + title: 'Search nodes', + icon: { html: '🔍' }, + section: 'Tools', + placeholder: 'Search nodes…', + children: [ + { id: 'node-http-request', title: 'HTTP Request' }, + { id: 'node-set', title: 'Set' }, + ], + hasMoreChildren: true, + }, +]; + +async function openCommandBar() { + const ev = new KeyboardEvent('keydown', { key: 'k', metaKey: true }); + document.dispatchEvent(ev); + await waitFor(() => expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()); +} + +describe('components', () => { + describe('N8nCommandBar', () => { + it('opens with Cmd/Ctrl+K and closes with Escape', async () => { + const wrapper = render(N8nCommandBar, { + props: { items: createSampleItems() }, + }); + + await openCommandBar(); + + const esc = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(esc); + + await waitFor(() => + expect(screen.queryByPlaceholderText('Type a command...')).not.toBeInTheDocument(), + ); + // sanity: no leaks + expect(wrapper.emitted()).toBeDefined(); + }); + + it('emits inputChange and filters results as user types', async () => { + const wrapper = render(N8nCommandBar, { + props: { items: createSampleItems() }, + }); + + await openCommandBar(); + const input = screen.getByPlaceholderText('Type a command...'); + + await fireEvent.update(input, 'import'); + await nextTick(); + + expect(screen.getByText('Import workflow')).toBeInTheDocument(); + expect(screen.queryByText('Create new workflow')).not.toBeInTheDocument(); + + const events = wrapper.emitted('inputChange') ?? []; + expect(events.length).toBeGreaterThan(0); + expect(events[events.length - 1]).toEqual(['import']); + }); + + it('renders ungrouped items before section headers and items', async () => { + render(N8nCommandBar, { props: { items: createSampleItems() } }); + await openCommandBar(); + + const recent = screen.getByText('Recent: Customer Sync'); + const actionsHeader = screen.getByText('Actions'); + + expect(recent.compareDocumentPosition(actionsHeader)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('navigates into children on click and back with ArrowLeft', async () => { + render(N8nCommandBar, { props: { items: createSampleItems() } }); + await openCommandBar(); + + await fireEvent.click(screen.getByText('Search nodes')); + + await waitFor(() => expect(screen.getByPlaceholderText('Search nodes…')).toBeInTheDocument()); + + expect(screen.getByText('HTTP Request')).toBeInTheDocument(); + + const left = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); + document.dispatchEvent(left); + + await waitFor(() => + expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument(), + ); + + expect(screen.getByText('Search nodes')).toBeInTheDocument(); + }); + + it('invokes item handler on selection and closes the command bar', async () => { + const onCreate = vi.fn(); + const items = createSampleItems().map((it) => + it.id === 'create' ? { ...it, handler: onCreate } : it, + ); + + render(N8nCommandBar, { props: { items } }); + await openCommandBar(); + + await fireEvent.click(screen.getByText('Create new workflow')); + + expect(onCreate).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(screen.queryByPlaceholderText('Type a command...')).not.toBeInTheDocument(), + ); + }); + + it('closes when clicking outside the command bar', async () => { + render(N8nCommandBar, { props: { items: createSampleItems() } }); + await openCommandBar(); + + await fireEvent.click(document.body); + + await waitFor(() => + expect(screen.queryByPlaceholderText('Type a command...')).not.toBeInTheDocument(), + ); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue index 2eb3924c05b..d09885b639d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue @@ -1,28 +1,36 @@ @@ -39,40 +64,20 @@ const handleSelect = () => { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts index f2bebf8a81e..d2fc9d5ae41 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts @@ -2,12 +2,11 @@ import type { Component } from 'vue'; export interface CommandBarItem { id: string; - title: string; + title: string | { component: Component; props?: Record }; icon?: { html: string } | { component: Component; props?: Record }; section?: string; keywords?: string[]; handler?: () => void | Promise; - href?: string; children?: CommandBarItem[]; placeholder?: string; hasMoreChildren?: boolean; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue index f118f4a7cbf..b5e8b9443ac 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue @@ -507,7 +507,7 @@ const table = useVueTable({ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue b/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue index 0a9eebcb912..65f9e21a14e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue @@ -276,9 +276,9 @@ defineExpose({ inputRef }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInfoTip/InfoTip.vue b/packages/frontend/@n8n/design-system/src/components/N8nInfoTip/InfoTip.vue index 7c5ddb645f7..d4a7dfd7e65 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInfoTip/InfoTip.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInfoTip/InfoTip.vue @@ -105,26 +105,26 @@ const iconData = computed<{ icon: IconName; color: IconColor }>(() => { } .base { - font-size: var(--font-size-2xs); - line-height: var(--font-size-s); + font-size: var(--font-size--2xs); + line-height: var(--font-size--sm); word-break: normal; display: flex; align-items: center; svg { - font-size: var(--font-size-s); + font-size: var(--font-size--sm); } } .bold { - font-weight: var(--font-weight-medium); + font-weight: var(--font-weight--medium); } .note { composes: base; svg { - margin-right: var(--spacing-4xs); + margin-right: var(--spacing--4xs); } } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue index db91f01b609..546357a0df9 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue @@ -139,11 +139,11 @@ defineExpose({ forceFocus, forceCancel }); &::after { content: ''; position: absolute; - top: calc(var(--spacing-4xs) * -1); - left: calc(var(--spacing-3xs) * -1); - width: calc(100% + var(--spacing-xs)); - height: calc(100% + var(--spacing-2xs)); - border-radius: var(--border-radius-base); + top: calc(var(--spacing--4xs) * -1); + left: calc(var(--spacing--3xs) * -1); + width: calc(100% + var(--spacing--xs)); + height: calc(100% + var(--spacing--2xs)); + border-radius: var(--radius); background-color: var(--color--foreground--tint-2); opacity: 0; z-index: 0; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue index 9933c528a03..4e6743ddaa9 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue @@ -100,7 +100,7 @@ defineExpose({ focus, blur, select }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue b/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue index a537351c631..f5c46418a9e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue @@ -38,22 +38,22 @@ const keys = computed(() => { .shortcut { display: flex; align-items: center; - gap: var(--spacing-4xs); + gap: var(--spacing--4xs); } .keyWrapper { display: flex; justify-content: center; align-items: center; - border-radius: var(--border-radius-small); + border-radius: var(--radius--sm); height: 18px; min-width: 18px; - padding: 0 var(--spacing-4xs); + padding: 0 var(--spacing--4xs); border: solid 1px var(--color--foreground); background: var(--color--background); } .key { color: var(--color--text); - font-size: var(--font-size-3xs); + font-size: var(--font-size--3xs); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nLink/Link.vue b/packages/frontend/@n8n/design-system/src/components/N8nLink/Link.vue index cbce7e60ba8..6257f42f583 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nLink/Link.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nLink/Link.vue @@ -70,10 +70,10 @@ withDefaults(defineProps(), { } .secondary { - color: var(--color-secondary-link); + color: var(--link--color--secondary); &:active { - color: var(--color-secondary-link-hover); + color: var(--link--color--secondary--hover); } } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nLogo/Logo.vue b/packages/frontend/@n8n/design-system/src/components/N8nLogo/Logo.vue index a99510da529..c46568cdcfd 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nLogo/Logo.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nLogo/Logo.vue @@ -70,7 +70,7 @@ onMounted(() => { } .logoText { - margin-left: var(--spacing-5xs); + margin-left: var(--spacing--5xs); path { fill: var(--color--text--shade-1); } @@ -78,7 +78,7 @@ onMounted(() => { .large { transform: scale(2); - margin-bottom: var(--spacing-xl); + margin-bottom: var(--spacing--xl); .logo, .logoText { @@ -86,18 +86,18 @@ onMounted(() => { } .logoText { - margin-left: var(--spacing-xs); - margin-right: var(--spacing-3xs); + margin-left: var(--spacing--xs); + margin-right: var(--spacing--3xs); } } .sidebarExpanded .logo { - margin-left: var(--spacing-2xs); + margin-left: var(--spacing--2xs); } .sidebarCollapsed .logo { width: 40px; height: 30px; - padding: 0 var(--spacing-4xs); + padding: 0 var(--spacing--4xs); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue index b75278e28e2..5f7434c144d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue @@ -249,33 +249,33 @@ const onCheckboxChange = (index: number) => { color: var(--color--text); * { - font-size: var(--font-size-m); - line-height: var(--font-line-height-xloose); + font-size: var(--font-size--md); + line-height: var(--line-height--xl); } h1, h2, h3, h4 { - margin-bottom: var(--spacing-s); - font-size: var(--font-size-m); - font-weight: var(--font-weight-bold); + margin-bottom: var(--spacing--sm); + font-size: var(--font-size--md); + font-weight: var(--font-weight--bold); } h3, h4 { - font-weight: var(--font-weight-bold); + font-weight: var(--font-weight--bold); } p, span { - margin-bottom: var(--spacing-s); + margin-bottom: var(--spacing--sm); } ul, ol { - margin-bottom: var(--spacing-s); - padding-left: var(--spacing-m); + margin-bottom: var(--spacing--sm); + padding-left: var(--spacing--md); li { margin-top: 0.25em; @@ -289,7 +289,7 @@ const onCheckboxChange = (index: number) => { li > code, p > code { - padding: 0 var(--spacing-4xs); + padding: 0 var(--spacing--4xs); color: var(--color--text--shade-1); background-color: var(--color--background); } @@ -300,13 +300,13 @@ const onCheckboxChange = (index: number) => { img { max-width: 100%; - border-radius: var(--border-radius-large); + border-radius: var(--radius--lg); } blockquote { padding-left: 10px; font-style: italic; - border-left: var(--border-color-base) 2px solid; + border-left: var(--border-color) 2px solid; } } @@ -319,7 +319,7 @@ input[type='checkbox'] + label { } .sticky { - color: var(--color-sticky-font); + color: var(--sticky--color--text); overflow-wrap: break-word; h1, @@ -328,16 +328,16 @@ input[type='checkbox'] + label { h4, h5, h6 { - color: var(--color-sticky-font); + color: var(--sticky--color--text); } h1, h2, h3, h4 { - margin-bottom: var(--spacing-2xs); - font-weight: var(--font-weight-bold); - line-height: var(--font-line-height-loose); + margin-bottom: var(--spacing--2xs); + font-weight: var(--font-weight--bold); + line-height: var(--line-height--lg); } h1 { @@ -352,43 +352,43 @@ input[type='checkbox'] + label { h4, h5, h6 { - font-size: var(--font-size-m); + font-size: var(--font-size--md); } p { - margin-bottom: var(--spacing-2xs); - font-size: var(--font-size-s); - font-weight: var(--font-weight-regular); - line-height: var(--font-line-height-loose); + margin-bottom: var(--spacing--2xs); + font-size: var(--font-size--sm); + font-weight: var(--font-weight--regular); + line-height: var(--line-height--lg); } ul, ol { - margin-bottom: var(--spacing-2xs); - padding-left: var(--spacing-m); + margin-bottom: var(--spacing--2xs); + padding-left: var(--spacing--md); li { margin-top: 0.25em; - font-size: var(--font-size-s); - font-weight: var(--font-weight-regular); - line-height: var(--font-line-height-regular); + font-size: var(--font-size--sm); + font-weight: var(--font-weight--regular); + line-height: var(--line-height--md); } &:has(input[type='checkbox']) { list-style-type: none; - padding-left: var(--spacing-5xs); + padding-left: var(--spacing--5xs); } } pre > code { - background-color: var(--color-sticky-code-background); - color: var(--color-sticky-code-font); + background-color: var(--sticky--code--color--background); + color: var(--sticky--code--color--text); } pre > code, li > code, p > code { - color: var(--color-sticky-code-font); + color: var(--sticky--code--color--text); } a { @@ -399,8 +399,8 @@ input[type='checkbox'] + label { img { object-fit: contain; - margin-top: var(--spacing-xs); - margin-bottom: var(--spacing-2xs); + margin-top: var(--spacing--xs); + margin-bottom: var(--spacing--2xs); &[src*='#full-width'] { width: 100%; @@ -411,13 +411,13 @@ input[type='checkbox'] + label { .sticky, .markdown { pre { - margin-bottom: var(--spacing-s); + margin-bottom: var(--spacing--sm); display: grid; } pre > code { display: block; - padding: var(--spacing-s); + padding: var(--spacing--sm); overflow-x: auto; } @@ -431,6 +431,6 @@ input[type='checkbox'] + label { } .spacer { - margin: var(--spacing-2xl); + margin: var(--spacing--2xl); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue index fc9068fadc1..947585dbbac 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -95,7 +95,7 @@ const iconColor = computed(() => { position: relative; width: 100%; max-width: 100%; - margin-bottom: var(--spacing-5xs); + margin-bottom: var(--spacing--5xs); } .router-link-active, @@ -107,11 +107,11 @@ const iconColor = computed(() => { display: flex; align-items: center; justify-content: center; - padding: var(--spacing-3xs); - gap: var(--spacing-3xs); + padding: var(--spacing--3xs); + gap: var(--spacing--3xs); cursor: pointer; color: var(--color--text); - border-radius: var(--spacing-4xs); + border-radius: var(--spacing--4xs); cursor: pointer; min-width: 0; width: 100%; @@ -137,7 +137,7 @@ const iconColor = computed(() => { text-overflow: ellipsis; overflow: hidden; flex: 1; - line-height: var(--font-size-l); + line-height: var(--font-size--lg); min-width: 0; } @@ -147,24 +147,24 @@ const iconColor = computed(() => { .menuItemIcon { position: relative; - width: var(--spacing-s); - height: var(--spacing-s); - min-width: var(--spacing-s); + width: var(--spacing--sm); + height: var(--spacing--sm); + min-width: var(--spacing--sm); &.notification::after { content: ''; position: absolute; - top: calc(var(--spacing-5xs) * -1); - right: calc(var(--spacing-5xs) * -1); - width: var(--spacing-4xs); - height: var(--spacing-4xs); + top: calc(var(--spacing--5xs) * -1); + right: calc(var(--spacing--5xs) * -1); + width: var(--spacing--4xs); + height: var(--spacing--4xs); background-color: var(--color--danger); border-radius: 50%; } } .menuItemEmoji { - font-size: var(--spacing-s); + font-size: var(--spacing--sm); line-height: 1; } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index 73ac9baf882..07a4091aa95 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -39,6 +39,12 @@ const emit = defineEmits<{ select: [id: Item['id']]; }>(); +defineSlots<{ + default?: () => unknown; + 'item-icon'?: (props: { item: BaseItem }) => unknown; + [key: `item.append.${string}`]: (props: { item: Item }) => unknown; +}>(); + const close = () => { menuRef.value?.close(ROOT_MENU_INDEX); }; @@ -93,7 +99,24 @@ defineExpose({ :popper-offset="-10" data-test-id="navigation-submenu" > - + @@ -165,11 +165,11 @@ ul.user-stack-list { border: none; display: flex; flex-direction: column; - gap: var(--spacing-s); - padding-bottom: var(--spacing-2xs); + gap: var(--spacing--sm); + padding-bottom: var(--spacing--2xs); .el-dropdown-menu__item { - line-height: var(--font-line-height-regular); + line-height: var(--line-height--md); } li:hover { @@ -178,9 +178,9 @@ ul.user-stack-list { } .user-stack-popper { - border: 1px solid var(--border-color-light); - border-radius: var(--border-radius-base); - padding: var(--spacing-5xs) 0; + border: 1px solid var(--border-color--light); + border-radius: var(--radius); + padding: var(--spacing--5xs) 0; box-shadow: 0 2px 8px 0 #441c171a; background-color: var(--color--background--light-3); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue b/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue index 00684947281..ce2d0b5e4b2 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nUsersList/UsersList.vue @@ -119,7 +119,7 @@ const onUserAction = (user: UserType, action: string) => diff --git a/packages/frontend/@n8n/design-system/src/components/TableBase/TableBase.vue b/packages/frontend/@n8n/design-system/src/components/TableBase/TableBase.vue index f24e6509eec..62d60628b54 100644 --- a/packages/frontend/@n8n/design-system/src/components/TableBase/TableBase.vue +++ b/packages/frontend/@n8n/design-system/src/components/TableBase/TableBase.vue @@ -21,7 +21,7 @@ border-radius: 8px; border: 1px solid var(--color--foreground); overflow: hidden; - font-size: var(--font-size-s); + font-size: var(--font-size--sm); table { width: 100%; diff --git a/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue index 062775b9179..05406188158 100644 --- a/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue +++ b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue @@ -228,9 +228,9 @@ const handleDragEnd = () => { diff --git a/packages/frontend/@n8n/design-system/src/styleguide/fonts.stories.ts b/packages/frontend/@n8n/design-system/src/styleguide/fonts.stories.ts index 0b83c253275..c051ed5f3e2 100644 --- a/packages/frontend/@n8n/design-system/src/styleguide/fonts.stories.ts +++ b/packages/frontend/@n8n/design-system/src/styleguide/fonts.stories.ts @@ -12,7 +12,7 @@ export const FontSize: StoryFn = () => ({ Sizes, }, template: - "", + "", }); const Template = @@ -25,11 +25,11 @@ const Template = }); export const LineHeight = Template( - "", + "", ); export const FontWeight = Template( - '', + '', ); export const FontFamily = Template( diff --git a/packages/frontend/@n8n/design-system/src/styleguide/spacing.stories.ts b/packages/frontend/@n8n/design-system/src/styleguide/spacing.stories.ts index 709c0e531a3..3a0ae0b40f9 100644 --- a/packages/frontend/@n8n/design-system/src/styleguide/spacing.stories.ts +++ b/packages/frontend/@n8n/design-system/src/styleguide/spacing.stories.ts @@ -17,5 +17,5 @@ export const Spacing: StoryFn = () => ({ Sizes, }, template: - "", + "", }); diff --git a/packages/frontend/@n8n/i18n/package.json b/packages/frontend/@n8n/i18n/package.json index 8ff99d9a022..5eb222675f8 100644 --- a/packages/frontend/@n8n/i18n/package.json +++ b/packages/frontend/@n8n/i18n/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/i18n", "type": "module", - "version": "1.19.0", + "version": "1.20.0", "files": [ "dist" ], @@ -21,8 +21,8 @@ } }, "scripts": { - "dev": "vite", - "build": "tsup", + "dev": "tsdown --watch", + "build": "tsdown", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", "test": "vitest run", @@ -47,7 +47,7 @@ "@vue/tsconfig": "catalog:frontend", "@vueuse/core": "catalog:frontend", "vue": "catalog:frontend", - "tsup": "catalog:", + "tsdown": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 64f37ec9f17..b1d42ae44a3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -42,6 +42,7 @@ "generic.any": "Any", "generic.cancel": "Cancel", "generic.open": "Open", + "generic.add": "Add", "generic.close": "Close", "generic.clear": "Clear", "generic.confirm": "Confirm", @@ -226,6 +227,7 @@ "aiAssistant.builder.executeMessage.executionSuccess": "Workflow executed successfully.", "aiAssistant.builder.executeMessage.executionFailedOnNode": "Workflow execution failed on node \"{nodeName}\": {errorMessage}", "aiAssistant.builder.executeMessage.executionFailed": "Workflow execution failed: {errorMessage}", + "aiAssistant.builder.executeMessage.fillParameter": "Update \"{label}\" parameter", "aiAssistant.builder.toast.title": "Send chat message to start the execution", "aiAssistant.builder.toast.description": "Please send a message in the chat panel to start the execution of your workflow", "aiAssistant.assistant": "n8n AI", @@ -936,6 +938,10 @@ "executionView.notFound.message": "Execution with id '{executionId}' could not be found!", "executionAnnotationView.data.notFound": "Show important data from executions here by adding an execution data node to your workflow", "executionAnnotationView.vote.error": "Unable to save annotation vote", + "executionAnnotationView.vote.up": "Vote up", + "executionAnnotationView.vote.down": "Vote down", + "executionAnnotationView.vote.removeUp": "Remove up vote", + "executionAnnotationView.vote.removeDown": "Remove down vote", "executionAnnotationView.tag.error": "Unable to save annotation tags", "executionAnnotationView.addTag": "Add tag", "executionAnnotationView.chooseOrCreateATag": "Choose or create a tag", @@ -1151,6 +1157,10 @@ "mainSidebar.whatsNew": "What’s New", "mainSidebar.whatsNew.fullChangelog": "Full changelog", "mcp.workflowNotEligable.description": "Only active, webhook-triggered workflows can be accessible through MCP", + "mcp.workflowDeactivated.title": "MCP Access Disabled", + "mcp.productionCheklist.title": "Enable MCP access", + "mcp.productionCheklist.description": "Allow MCP clients to access this workflow", + "mcp.workflowDeactivated.message": "MCP Access has been disabled for this workflow because it is deactivated.", "menuActions.duplicate": "Duplicate", "menuActions.download": "Download", "menuActions.push": "Push to Git", @@ -1298,6 +1308,7 @@ "node.delete": "Delete", "node.add": "Add", "node.issues": "Issues", + "node.install-to-use": "Install the package to use this node", "node.dirty": "Node configuration changed. Output data may change when this node is run again", "node.subjectToChange": "Because of changes in the workflow, output data may change when this node is run again", "node.nodeIsExecuting": "Node is executing", @@ -1455,6 +1466,7 @@ "nodeCredentials.selectedCredentialUnavailable": "{name} (unavailable)", "nodeCredentials.showMessage.message": "Nodes that used credential \"{oldCredentialName}\" have been updated to use \"{newCredentialName}\"", "nodeCredentials.showMessage.title": "Node credential updated", + "nodeCredentials.autoAssigned.message": "Added this credential to {count} other node(s)", "nodeCredentials.updateCredential": "Update Credential", "nodeErrorView.cause": "Cause", "nodeErrorView.copyToClipboard": "Copy to Clipboard", @@ -1535,8 +1547,11 @@ "nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown": "The node is not valid as its type ({nodeType}) is unknown", "nodeSettings.communityNodeDetails.title": "Node details", "nodeSettings.communityNodeUnknown.title": "Install this node to use it", + "nodeSettings.communityNodeUnknown.title.preview": "Detailed node info is not available", "nodeSettings.communityNodeUnknown.description": "This node is not currently installed. It's part of the {action} community package.", "nodeSettings.communityNodeUnknown.installLink.text": "How to install community nodes", + "nodeSettings.communityNodeUnknown.installButton.label": "Install", + "nodeSettings.communityNodeUnknown.viewDetailsButton.label": "View details", "nodeSettings.nodeTypeUnknown.description": "This node is not currently installed. It is either from a newer version of n8n, a {action}, or has an invalid structure", "nodeSettings.nodeTypeUnknown.description.customNode": "custom node", "nodeSettings.thisNodeDoesNotHaveAnyParameters": "This node does not have any parameters", @@ -1553,6 +1568,7 @@ "nodeView.addNode": "Add node", "nodeView.openFocusPanel": "Open focus panel", "nodeView.openNodesPanel": "Open nodes panel", + "nodeView.openCommandBar": "Command bar", "nodeView.addATriggerNodeFirst": "Add a Trigger Node first", "nodeView.addOrEnableTriggerNode": "Add or enable a Trigger node to execute the workflow", "nodeView.addSticky": "Click to add sticky note", @@ -2693,6 +2709,7 @@ "workflowSettings.showError.saveSettings2.message": "The timeout is longer than allowed", "workflowSettings.showError.saveSettings2.title": "Problem saving settings", "workflowSettings.showError.saveSettings3.title": "Problem saving settings", + "workflowSettings.showError.fetchSettings.title": "Problem fetching settings", "workflowSettings.showMessage.saveSettings.title": "Workflow settings saved", "workflowSettings.timeoutAfter": "Timeout After", "workflowSettings.timeoutWorkflow": "Timeout Workflow", @@ -2803,6 +2820,7 @@ "workflows.empty.shared-with-me.link": "Back to Personal", "workflows.empty.readyToRunV2": "Try an AI workflow", "workflows.list.easyAI": "Test the power of AI in n8n with this simple AI Agent Workflow", + "workflows.list.error.fetching.one": "Error fetching workflow", "workflows.list.error.fetching": "Error fetching workflows", "workflows.shareModal.title": "Share '{name}'", "workflows.shareModal.title.static": "Shared with {projectName}", @@ -2884,10 +2902,11 @@ "variables.table.key": "Key", "variables.table.value": "Value", "variables.table.usage": "Usage Syntax", + "variables.table.scope": "Scope", + "variables.table.scope.global": "Global", "variables.editing.key.placeholder": "Enter a name", "variables.editing.value.placeholder": "Enter a value", - "variables.editing.key.error.startsWithLetter": "This field may only start with a letter", - "variables.editing.key.error.jsonKey": "This field may contain only letters, numbers, and underscores", + "variables.editing.key.error.regex": "This field may contain only letters, numbers, and underscores", "variables.row.button.save": "Save", "variables.row.button.cancel": "Cancel", "variables.row.button.edit": "Edit", @@ -2897,12 +2916,24 @@ "variables.row.usage.copiedToClipboard": "Copied to clipboard", "variables.row.usage.copyToClipboard": "Copy to clipboard", "variables.search.placeholder": "Search variables...", + "variables.delete.successful.message": "Variable {variableName} deleted", "variables.errors.save": "Error while saving variable", "variables.errors.delete": "Error while deleting variable", "variables.modals.deleteConfirm.title": "Delete variable", "variables.modals.deleteConfirm.message": "Are you sure you want to delete the variable \"{name}\"? This cannot be undone.", "variables.modals.deleteConfirm.confirmButton": "Delete", "variables.modals.deleteConfirm.cancelButton": "Cancel", + "variables.modal.title.create": "New variable", + "variables.modal.title.edit": "Edit variable", + "variables.modal.key.label": "Key", + "variables.modal.value.label": "Value", + "variables.modal.scope.label": "Scope", + "variables.modal.scope.global": "Global", + "variables.modal.scope.all": "All", + "variables.modal.button.cancel": "Cancel", + "variables.modal.button.save": "Save", + "variables.modal.error.keyExistsInProject": "The key already exists in this project", + "variables.modal.warning.globalKeyExists": "The same key already exists in global scope. This variable will override the global variable inside the project", "contextual.credentials.sharing.unavailable.title": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.title.cloud": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.description": "You can share credentials with others when you upgrade your plan.", @@ -3574,15 +3605,22 @@ "preBuiltAgentTemplates.title": "Pre-built agents", "preBuiltAgentTemplates.tutorials": "Tutorial templates", "preBuiltAgentTemplates.viewAllLink": "View all templates", - "commandBar.placeholder": "Type a command...", + "commandBar.placeholder": "Type a command or search...", "commandBar.noResults": "No results found", + "commandBar.sections.recent": "Recent", "commandBar.sections.nodes": "Nodes", "commandBar.sections.workflow": "Workflow", "commandBar.sections.workflows": "Workflows", "commandBar.sections.credentials": "Credentials", "commandBar.sections.dataTables": "Data Tables", - "commandBar.sections.templates": "Templates", + "commandBar.sections.execution": "Execution", + "commandBar.sections.executions": "Executions", + "commandBar.sections.evaluation": "Evaluation", "commandBar.sections.demo": "Demo", + "commandBar.sections.general": "Navigation", + "commandBar.sections.templates": "Templates", + "commandBar.templates.import": "Import template", + "commandBar.templates.importWithPrefix": "Import template {templateName}", "commandBar.nodes.addNode": "Add node", "commandBar.nodes.addStickyNote": "Add sticky note", "commandBar.nodes.openNode": "Open node", @@ -3593,45 +3631,48 @@ "commandBar.nodes.keywords.add": "add", "commandBar.nodes.keywords.create": "create", "commandBar.nodes.keywords.node": "node", - "commandBar.workflow.test": "Test workflow", + "commandBar.workflow.test": "Execute workflow", "commandBar.workflow.save": "Save workflow", "commandBar.workflow.activate": "Activate workflow", "commandBar.workflow.deactivate": "Deactivate workflow", "commandBar.workflow.selectAll": "Select all", "commandBar.workflow.tidyUp": "Tidy up workflow", + "commandBar.workflow.rename": "Rename workflow", "commandBar.workflow.duplicate": "Duplicate workflow", + "commandBar.workflow.openSettings": "Open workflow settings", "commandBar.workflow.download": "Download workflow", + "commandBar.workflow.importFromURL": "Import workflow from URL", + "commandBar.workflow.importFromFile": "Import workflow from file", "commandBar.workflow.openCredential": "Open credential", - "commandBar.workflow.openSubworkflow": "Open subworkflow", + "commandBar.workflow.openSubworkflow": "Open sub-workflow", + "commandBar.workflow.archive": "Archive workflow", + "commandBar.workflow.unarchive": "Unarchive workflow", + "commandBar.workflow.delete": "Delete workflow", "commandBar.workflow.keywords.test": "test", "commandBar.workflow.keywords.execute": "execute", "commandBar.workflow.keywords.run": "run", "commandBar.workflow.keywords.workflow": "workflow", + "commandBar.workflow.keywords.download": "download", + "commandBar.workflow.keywords.export": "export", + "commandBar.workflow.keywords.archive": "archive", + "commandBar.workflow.keywords.delete": "delete", + "commandBar.workflow.keywords.unarchive": "unarchive", + "commandBar.workflow.keywords.restore": "restore", "commandBar.workflows.create": "Create workflow in {projectName}", "commandBar.workflows.open": "Open workflow", "commandBar.workflows.searchPlaceholder": "Search by workflow name or node type...", - "commandBar.workflows.prefixPersonal": "[Personal] > ", - "commandBar.workflows.prefixProject": "[{projectName}] > ", - "commandBar.workflows.openPrefixPersonal": "Open workflow > [Personal] > ", - "commandBar.workflows.openPrefixProject": "Open workflow > [{projectName}] > ", "commandBar.workflows.unnamed": "(unnamed workflow)", "commandBar.credentials.create": "Create credential in {projectName}", "commandBar.credentials.open": "Open credential", "commandBar.credentials.searchPlaceholder": "Search by credential name...", - "commandBar.credentials.prefixPersonal": "[Personal] > ", - "commandBar.credentials.prefixProject": "[{projectName}] > ", - "commandBar.credentials.openPrefixPersonal": "Open credential > [Personal] > ", - "commandBar.credentials.openPrefixProject": "Open credential > [{projectName}] > ", - "commandBar.credentials.unnamed": "(unnamed credential)", "commandBar.dataTables.create": "Create data table in {projectName}", "commandBar.dataTables.open": "Open data table", "commandBar.dataTables.searchPlaceholder": "Search by data table name...", - "commandBar.dataTables.prefixPersonal": "[Personal] > ", - "commandBar.dataTables.prefixProject": "[{projectName}] > ", - "commandBar.dataTables.openPrefixPersonal": "Open data table > [Personal] > ", - "commandBar.dataTables.openPrefixProject": "Open data table > [{projectName}] > ", - "commandBar.dataTables.unnamed": "(unnamed data table)", - "commandBar.templates.import": "Import template", - "commandBar.templates.importWithPrefix": "Import template > {templateName}", - "commandBar.demo.availableEverywhere": "This is available everywhere" + "commandBar.executions.open": "Open executions", + "commandBar.demo.availableEverywhere": "This is available everywhere", + "commandBar.sections.projects": "Projects", + "commandBar.projects.create": "Create project", + "commandBar.projects.open": "Open project", + "commandBar.projects.searchPlaceholder": "Search by project name...", + "commandBar.projects.unnamed": "(unnamed project)" } diff --git a/packages/frontend/@n8n/i18n/tsconfig.json b/packages/frontend/@n8n/i18n/tsconfig.json index 63e10d0192e..730ae85d455 100644 --- a/packages/frontend/@n8n/i18n/tsconfig.json +++ b/packages/frontend/@n8n/i18n/tsconfig.json @@ -8,5 +8,5 @@ "isolatedModules": true, "resolveJsonModule": true }, - "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsup.config.ts"] + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsdown.config.ts"] } diff --git a/packages/frontend/@n8n/i18n/tsup.config.ts b/packages/frontend/@n8n/i18n/tsdown.config.ts similarity index 72% rename from packages/frontend/@n8n/i18n/tsup.config.ts rename to packages/frontend/@n8n/i18n/tsdown.config.ts index bff21e25504..3526299087a 100644 --- a/packages/frontend/@n8n/i18n/tsup.config.ts +++ b/packages/frontend/@n8n/i18n/tsdown.config.ts @@ -1,11 +1,9 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'], format: ['cjs', 'esm'], clean: true, dts: true, - cjsInterop: true, - splitting: true, sourcemap: true, }); diff --git a/packages/frontend/@n8n/rest-api-client/package.json b/packages/frontend/@n8n/rest-api-client/package.json index a0d28d53dcc..e0eebfe1dd3 100644 --- a/packages/frontend/@n8n/rest-api-client/package.json +++ b/packages/frontend/@n8n/rest-api-client/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/rest-api-client", "type": "module", - "version": "1.18.0", + "version": "1.19.0", "files": [ "dist" ], @@ -21,8 +21,8 @@ } }, "scripts": { - "dev": "vite", - "build": "tsup", + "dev": "tsdown --watch", + "build": "tsdown", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", "test": "vitest run", @@ -49,7 +49,7 @@ "@n8n/vitest-config": "workspace:*", "@testing-library/jest-dom": "catalog:frontend", "@testing-library/user-event": "catalog:frontend", - "tsup": "catalog:", + "tsdown": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/frontend/@n8n/rest-api-client/tsconfig.json b/packages/frontend/@n8n/rest-api-client/tsconfig.json index e51e37331b7..91735cff11c 100644 --- a/packages/frontend/@n8n/rest-api-client/tsconfig.json +++ b/packages/frontend/@n8n/rest-api-client/tsconfig.json @@ -10,5 +10,5 @@ "@n8n/utils/*": ["../../../@n8n/utils/src/*"] } }, - "include": ["src/**/*.ts", "vite.config.ts", "tsup.config.ts"] + "include": ["src/**/*.ts", "vite.config.ts", "tsdown.config.ts"] } diff --git a/packages/frontend/@n8n/stores/tsup.config.ts b/packages/frontend/@n8n/rest-api-client/tsdown.config.ts similarity index 72% rename from packages/frontend/@n8n/stores/tsup.config.ts rename to packages/frontend/@n8n/rest-api-client/tsdown.config.ts index bff21e25504..3526299087a 100644 --- a/packages/frontend/@n8n/stores/tsup.config.ts +++ b/packages/frontend/@n8n/rest-api-client/tsdown.config.ts @@ -1,11 +1,9 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'], format: ['cjs', 'esm'], clean: true, dts: true, - cjsInterop: true, - splitting: true, sourcemap: true, }); diff --git a/packages/frontend/@n8n/stores/package.json b/packages/frontend/@n8n/stores/package.json index bf293c44660..11b6e51607e 100644 --- a/packages/frontend/@n8n/stores/package.json +++ b/packages/frontend/@n8n/stores/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/stores", "type": "module", - "version": "1.22.0", + "version": "1.23.0", "files": [ "dist" ], @@ -21,8 +21,8 @@ } }, "scripts": { - "dev": "vite", - "build": "tsup", + "dev": "tsdown --watch", + "build": "tsdown", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", "test": "vitest run", @@ -47,7 +47,7 @@ "@vueuse/core": "catalog:frontend", "pinia": "catalog:frontend", "vue": "catalog:frontend", - "tsup": "catalog:", + "tsdown": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/packages/frontend/@n8n/stores/tsconfig.json b/packages/frontend/@n8n/stores/tsconfig.json index 10f9ee1b738..61cbaa9b324 100644 --- a/packages/frontend/@n8n/stores/tsconfig.json +++ b/packages/frontend/@n8n/stores/tsconfig.json @@ -7,5 +7,5 @@ "types": ["vite/client", "vitest/globals"], "isolatedModules": true }, - "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsup.config.ts"] + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsdown.config.ts"] } diff --git a/packages/frontend/@n8n/rest-api-client/tsup.config.ts b/packages/frontend/@n8n/stores/tsdown.config.ts similarity index 72% rename from packages/frontend/@n8n/rest-api-client/tsup.config.ts rename to packages/frontend/@n8n/stores/tsdown.config.ts index bff21e25504..3526299087a 100644 --- a/packages/frontend/@n8n/rest-api-client/tsup.config.ts +++ b/packages/frontend/@n8n/stores/tsdown.config.ts @@ -1,11 +1,9 @@ -import { defineConfig } from 'tsup'; +import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'], format: ['cjs', 'esm'], clean: true, dts: true, - cjsInterop: true, - splitting: true, sourcemap: true, }); diff --git a/packages/frontend/CLAUDE.md b/packages/frontend/CLAUDE.md index a994327b231..83292a36322 100644 --- a/packages/frontend/CLAUDE.md +++ b/packages/frontend/CLAUDE.md @@ -67,51 +67,51 @@ application. These variables cover colors, spacing, typography, and borders. #### Spacing ```css ---spacing-5xs: 2px ---spacing-4xs: 4px ---spacing-3xs: 6px ---spacing-2xs: 8px ---spacing-xs: 12px ---spacing-s: 16px ---spacing-m: 20px ---spacing-l: 24px ---spacing-xl: 32px ---spacing-2xl: 48px ---spacing-3xl: 64px ---spacing-4xl: 128px ---spacing-5xl: 256px +--spacing--5xs: 2px +--spacing--4xs: 4px +--spacing--3xs: 6px +--spacing--2xs: 8px +--spacing--xs: 12px +--spacing--sm: 16px +--spacing--md: 20px +--spacing--lg: 24px +--spacing--xl: 32px +--spacing--2xl: 48px +--spacing--3xl: 64px +--spacing--4xl: 128px +--spacing--5xl: 256px ``` #### Typography ```css ---font-size-3xs: 10px ---font-size-2xs: 12px ---font-size-xs: 13px ---font-size-s: 14px ---font-size-m: 16px ---font-size-l: 18px ---font-size-xl: 20px ---font-size-2xl: 28px +--font-size--3xs: 10px +--font-size--2xs: 12px +--font-size--xs: 13px +--font-size--sm: 14px +--font-size--md: 16px +--font-size--lg: 18px +--font-size--xl: 20px +--font-size--2xl: 28px ---font-line-height-compact: 1.25 ---font-line-height-regular: 1.3 ---font-line-height-loose: 1.35 ---font-line-height-xloose: 1.5 +--line-height--sm: 1.25 +--line-height--md: 1.3 +--line-height--lg: 1.35 +--line-height--xl: 1.5 ---font-weight-regular: 400 ---font-weight-bold: 600 +--font-weight--regular: 400 +--font-weight--bold: 600 --font-family: InterVariable, sans-serif ``` #### Borders ```css ---border-radius-small: 2px ---border-radius-base: 4px ---border-radius-large: 8px ---border-radius-xlarge: 12px +--radius--sm: 2px +--radius: 4px +--radius--lg: 8px +--radius--xl: 12px ---border-width-base: 1px ---border-style-base: solid ---border-base: var(--border-width-base) var(--border-style-base) var(--color--foreground) +--border-width: 1px +--border-style: solid +--border: var(--border-width) var(--border-style) var(--color--foreground) ``` diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 538acd4ce0f..e87f63de84a 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.115.0", + "version": "1.116.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "type": "module", @@ -15,8 +15,12 @@ "dev": "pnpm serve", "lint": "eslint src --quiet", "lint:fix": "eslint src --fix", - "lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter ../../@n8n/stylelint-config/dist/formatter-summary.js", - "lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter ../../@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles": "run-script-os", + "lint:styles:default": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter ../../@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles:windows": "stylelint \"src/**/*.{scss,sass,vue}\" --cache --custom-formatter \"%cd%/../../@n8n/stylelint-config/dist/formatter-summary.js\"", + "lint:styles:fix": "run-script-os", + "lint:styles:fix:default": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter ../../@n8n/stylelint-config/dist/formatter-summary.js", + "lint:styles:fix:windows": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache --custom-formatter \"%cd%/../../@n8n/stylelint-config/dist/formatter-summary.js\"", "format": "biome format --write . && prettier --write . --ignore-path ../../../.prettierignore", "format:check": "biome ci . && prettier --check . --ignore-path ../../../.prettierignore", "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 dev", @@ -110,6 +114,7 @@ "xss": "catalog:" }, "devDependencies": { + "run-script-os": "catalog:", "@faker-js/faker": "^8.0.2", "@iconify/json": "^2.2.349", "@n8n/eslint-config": "workspace:*", @@ -143,11 +148,5 @@ "vitest-mock-extended": "catalog:", "vue-tsc": "catalog:frontend", "z-vue-scan": "^0.0.35" - }, - "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "*", - "@fortawesome/free-regular-svg-icons": "*", - "@fortawesome/free-solid-svg-icons": "*", - "@fortawesome/vue-fontawesome": "*" } } diff --git a/packages/frontend/editor-ui/src/App.vue b/packages/frontend/editor-ui/src/App.vue index d71ac305e87..819fd6dd4a8 100644 --- a/packages/frontend/editor-ui/src/App.vue +++ b/packages/frontend/editor-ui/src/App.vue @@ -32,7 +32,7 @@ import { useRoute } from 'vue-router'; import { useStyles } from './composables/useStyles'; import { useExposeCssVar } from '@/composables/useExposeCssVar'; import { useFloatingUiOffsets } from '@/composables/useFloatingUiOffsets'; -import { useCommandBar } from './composables/useCommandBar'; +import { useCommandBar } from '@/features/ui/commandBar/composables/useCommandBar'; import { hasPermission } from './utils/rbac/permissions'; const route = useRoute(); @@ -43,19 +43,19 @@ const uiStore = useUIStore(); const usersStore = useUsersStore(); const settingsStore = useSettingsStore(); const ndvStore = useNDVStore(); +const { APP_Z_INDEXES } = useStyles(); const { initialize: initializeCommandBar, isEnabled: isCommandBarEnabled, items, + placeholder, + context, onCommandBarChange, onCommandBarNavigateTo, + isLoading: isCommandBarLoading, } = useCommandBar(); -const showCommandBar = computed( - () => isCommandBarEnabled.value && hasPermission(['authenticated']), -); - const { setAppZIndexes } = useStyles(); const { toastBottomOffset, askAiFloatingButtonBottomOffset } = useFloatingUiOffsets(); @@ -71,6 +71,10 @@ const isDemoMode = computed(() => route.name === VIEWS.DEMO); const hasContentFooter = ref(false); const appGrid = ref(null); +const showCommandBar = computed( + () => isCommandBarEnabled.value && hasPermission(['authenticated']) && !isDemoMode.value, +); + const chatPanelWidth = computed(() => chatPanelStore.width); useTelemetryContext({ ndv_source: computed(() => ndvStore.lastSetActiveNodeSource) }); @@ -172,6 +176,10 @@ useExposeCssVar('--ask-assistant-floating-button-bottom-offset', askAiFloatingBu diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 4f139e7ddd3..fb50778c1e1 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -64,7 +64,7 @@ import type { } from '@/constants'; import type { BulkCommand, Undoable } from '@/models/history'; -import type { ProjectSharingData } from '@/types/projects.types'; +import type { ProjectSharingData } from '@/features/projects/projects.types'; import type { IconName } from '@n8n/design-system/src/components/N8nIcon/icons'; import type { IUser, IUserResponse } from '@n8n/rest-api-client/api/users'; import type { @@ -300,6 +300,7 @@ export type VariableResource = BaseResource & { resourceType: 'variable'; key?: string; value?: string; + project?: { id: string; name: string }; }; export type CredentialsResource = BaseResource & { @@ -1160,3 +1161,9 @@ export interface LlmTokenUsageData { totalTokens: number; isEstimate: boolean; } + +export interface WorkflowValidationIssue { + node: string; + type: string; + value: string | string[]; +} diff --git a/packages/frontend/editor-ui/src/__tests__/data/index.ts b/packages/frontend/editor-ui/src/__tests__/data/index.ts deleted file mode 100644 index baba675788f..00000000000 --- a/packages/frontend/editor-ui/src/__tests__/data/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './canvas'; diff --git a/packages/frontend/editor-ui/src/__tests__/render.ts b/packages/frontend/editor-ui/src/__tests__/render.ts index 5f7652f5e33..0cd0da7f7e7 100644 --- a/packages/frontend/editor-ui/src/__tests__/render.ts +++ b/packages/frontend/editor-ui/src/__tests__/render.ts @@ -2,7 +2,6 @@ import type { Plugin } from 'vue'; import { render, type RenderOptions as TestingLibraryRenderOptions } from '@testing-library/vue'; import { i18nInstance } from '@n8n/i18n'; import { GlobalDirectivesPlugin } from '@/plugins/directives'; -import { FontAwesomePlugin } from '@/plugins/icons'; import { N8nPlugin } from '@n8n/design-system'; import type { Pinia } from 'pinia'; import { PiniaVuePlugin } from 'pinia'; @@ -33,14 +32,7 @@ const defaultOptions = { }, VueJsonPretty: vueJsonPretty, }, - plugins: [ - i18nInstance, - PiniaVuePlugin, - FontAwesomePlugin, - N8nPlugin, - GlobalDirectivesPlugin, - TelemetryPlugin, - ], + plugins: [i18nInstance, PiniaVuePlugin, N8nPlugin, GlobalDirectivesPlugin, TelemetryPlugin], }, }; diff --git a/packages/frontend/editor-ui/src/api/ai.ts b/packages/frontend/editor-ui/src/api/ai.ts index 2543c85a763..c2e81248dd0 100644 --- a/packages/frontend/editor-ui/src/api/ai.ts +++ b/packages/frontend/editor-ui/src/api/ai.ts @@ -80,20 +80,22 @@ export async function generateCodeForPrompt( ctx: IRestApiContext, { question, context, forNode }: AskAiRequest.RequestPayload, ): Promise<{ code: string }> { - return await makeRestApiRequest(ctx, 'POST', '/ai/ask-ai', { + const body: IDataObject = { question, context, forNode, - } as IDataObject); + }; + return await makeRestApiRequest(ctx, 'POST', '/ai/ask-ai', body); } export async function claimFreeAiCredits( ctx: IRestApiContext, { projectId }: { projectId?: string }, ): Promise { - return await makeRestApiRequest(ctx, 'POST', '/ai/free-credits', { + const body: IDataObject = { projectId, - } as IDataObject); + }; + return await makeRestApiRequest(ctx, 'POST', '/ai/free-credits', body); } export async function getAiSessions( @@ -106,9 +108,22 @@ export async function getAiSessions( lastUpdated: string; }>; }> { - return await makeRestApiRequest(ctx, 'POST', '/ai/sessions', { + const body: IDataObject = { workflowId, - } as IDataObject); + }; + return await makeRestApiRequest(ctx, 'POST', '/ai/sessions', body); +} + +export async function getSessionsMetadata( + ctx: IRestApiContext, + workflowId?: string, +): Promise<{ + hasMessages: boolean; +}> { + const body: IDataObject = { + workflowId, + }; + return await makeRestApiRequest(ctx, 'POST', '/ai/sessions/metadata', body); } export async function getBuilderCredits(ctx: IRestApiContext): Promise<{ diff --git a/packages/frontend/editor-ui/src/api/workflows.ts b/packages/frontend/editor-ui/src/api/workflows.ts index 3692d9e2c11..69ba39dc5fd 100644 --- a/packages/frontend/editor-ui/src/api/workflows.ts +++ b/packages/frontend/editor-ui/src/api/workflows.ts @@ -32,11 +32,17 @@ export async function getWorkflow(context: IRestApiContext, id: string) { return await makeRestApiRequest(context, 'GET', `/workflows/${id}`); } -export async function getWorkflows(context: IRestApiContext, filter?: object, options?: object) { +export async function getWorkflows( + context: IRestApiContext, + filter?: object, + options?: object, + select?: string[], +) { return await getFullApiResponse(context, 'GET', '/workflows', { includeScopes: true, ...(filter ? { filter } : {}), ...(options ? options : {}), + ...(select ? { select: JSON.stringify(select) } : {}), }); } diff --git a/packages/frontend/editor-ui/src/components/AboutModal.vue b/packages/frontend/editor-ui/src/components/AboutModal.vue index 9bcebbe87b6..f0ef63ce17f 100644 --- a/packages/frontend/editor-ui/src/components/AboutModal.vue +++ b/packages/frontend/editor-ui/src/components/AboutModal.vue @@ -132,7 +132,7 @@ const copyDebugInfoToClipboard = async () => { diff --git a/packages/frontend/editor-ui/src/components/ActivationModal.vue b/packages/frontend/editor-ui/src/components/ActivationModal.vue index 7b63bafd698..0406b341292 100644 --- a/packages/frontend/editor-ui/src/components/ActivationModal.vue +++ b/packages/frontend/editor-ui/src/components/ActivationModal.vue @@ -131,14 +131,14 @@ const handleCheckboxChange = (checkboxValue: string | number | boolean) => { diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue index b83e4b30a6d..945d4b70450 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue @@ -235,14 +235,14 @@ const onValueInputHoverChange = (hovered: boolean): void => { position: relative; display: flex; align-items: flex-end; - gap: var(--spacing-4xs); + gap: var(--spacing--4xs); &.hasIssues { - --input-border-color: var(--color--danger); + --input--border-color: var(--color--danger); } &.hasHint { - padding-bottom: var(--spacing-s); + padding-bottom: var(--spacing--sm); } &:hover { @@ -268,7 +268,7 @@ const onValueInputHoverChange = (hovered: boolean): void => { .hint { position: absolute; - bottom: calc(var(--spacing-s) * -1); + bottom: calc(var(--spacing--sm) * -1); left: 0; right: 0; font-family: monospace; @@ -287,11 +287,11 @@ const onValueInputHoverChange = (hovered: boolean): void => { color: var(--icon-base-color); } .extraTopPadding { - top: calc(20px + var(--spacing-l)); + top: calc(20px + var(--spacing--lg)); } .defaultTopPadding { - top: var(--spacing-l); + top: var(--spacing--lg); } .status { @@ -300,6 +300,6 @@ const onValueInputHoverChange = (hovered: boolean): void => { } .statusIcon { - padding-left: var(--spacing-4xs); + padding-left: var(--spacing--4xs); } diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue index 6933f14bf16..47f3dfe5564 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue @@ -224,30 +224,30 @@ function optionSelected(action: string) { .assignmentCollection { display: flex; flex-direction: column; - margin: var(--spacing-xs) 0; + margin: var(--spacing--xs) 0; } .content { display: flex; - gap: var(--spacing-l); + gap: var(--spacing--lg); flex-direction: column; } .assignments { display: flex; flex-direction: column; - gap: var(--spacing-4xs); + gap: var(--spacing--4xs); } .assignment { - padding-left: var(--spacing-l); + padding-left: var(--spacing--lg); } .dropAreaWrapper { cursor: pointer; &:not(.empty .dropAreaWrapper) { - padding-left: var(--spacing-l); + padding-left: var(--spacing--lg); } &:hover .add { @@ -260,7 +260,7 @@ function optionSelected(action: string) { align-items: center; flex-wrap: wrap; justify-content: center; - font-size: var(--font-size-xs); + font-size: var(--font-size--xs); color: var(--color--text--shade-1); gap: 1ch; min-height: 24px; @@ -276,17 +276,17 @@ function optionSelected(action: string) { .or { color: var(--color--text--tint-1); - font-size: var(--font-size-2xs); + font-size: var(--font-size--2xs); } .add { color: var(--color--primary); - font-weight: var(--font-weight-bold); + font-weight: var(--font-weight--bold); } .activeField { - font-weight: var(--font-weight-bold); - color: var(--color-ndv-droppable-parameter); + font-weight: var(--font-weight--bold); + color: var(--ndv--droppable-parameter--color); } .active { @@ -299,7 +299,7 @@ function optionSelected(action: string) { .dropArea { flex-direction: column; align-items: center; - gap: var(--spacing-3xs); + gap: var(--spacing--3xs); min-height: 20vh; } @@ -309,18 +309,18 @@ function optionSelected(action: string) { } .content { - gap: var(--spacing-s); + gap: var(--spacing--sm); } } .icon { - font-size: var(--font-size-2xl); + font-size: var(--font-size--2xl); } .ghost, .dragging { - border-radius: var(--border-radius-base); - padding-right: var(--spacing-xs); - padding-bottom: var(--spacing-xs); + border-radius: var(--radius); + padding-right: var(--spacing--xs); + padding-bottom: var(--spacing--xs); } .ghost { background-color: var(--color--background); diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/TypeSelect.vue b/packages/frontend/editor-ui/src/components/AssignmentCollection/TypeSelect.vue index 1a596fda83e..b078ce06ebd 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/TypeSelect.vue +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/TypeSelect.vue @@ -65,8 +65,8 @@ const onTypeChange = (type: string): void => { .option { display: flex; - gap: var(--spacing-2xs); + gap: var(--spacing--2xs); align-items: center; - font-size: var(--font-size-s); + font-size: var(--font-size--sm); } diff --git a/packages/frontend/editor-ui/src/components/Badge.vue b/packages/frontend/editor-ui/src/components/Badge.vue index 4f8d4681230..2a69e8ad25b 100644 --- a/packages/frontend/editor-ui/src/components/Badge.vue +++ b/packages/frontend/editor-ui/src/components/Badge.vue @@ -32,7 +32,7 @@ export default { font-size: 11px; line-height: 18px; max-height: 18px; - font-weight: var(--font-weight-regular); + font-weight: var(--font-weight--regular); display: flex; align-items: center; padding: 2px 4px; diff --git a/packages/frontend/editor-ui/src/components/Banner.vue b/packages/frontend/editor-ui/src/components/Banner.vue index abdd31e89ac..fbae8d273f8 100644 --- a/packages/frontend/editor-ui/src/components/Banner.vue +++ b/packages/frontend/editor-ui/src/components/Banner.vue @@ -97,14 +97,14 @@ const onClick = () => { .message { white-space: normal; - line-height: var(--font-line-height-regular); + line-height: var(--line-height--md); overflow: hidden; word-break: break-word; } .dangerMessage { composes: message; - color: var(--color-callout-danger-font); + color: var(--callout--color--text--danger); } .banner { @@ -121,12 +121,12 @@ const onClick = () => { .details { composes: message; - margin-top: var(--spacing-3xs); + margin-top: var(--spacing--3xs); color: var(--color--text); - font-size: var(--font-size-2xs); + font-size: var(--font-size--2xs); } .moreDetails { - font-size: var(--font-size-xs); + font-size: var(--font-size--xs); } diff --git a/packages/frontend/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue b/packages/frontend/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue index 919019875d7..2a6852ae1d7 100644 --- a/packages/frontend/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue +++ b/packages/frontend/editor-ui/src/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue @@ -51,21 +51,21 @@ const onClick = () => { display: flex; flex-direction: column; background-color: var(--color--background--light-2); - border: var(--border-base); + border: var(--border); border-right: 0; } .textAndCloseButton { display: flex; - margin-top: var(--spacing-xs); - margin-left: var(--spacing-s); - margin-right: var(--spacing-2xs); + margin-top: var(--spacing--xs); + margin-left: var(--spacing--sm); + margin-right: var(--spacing--2xs); } .text { flex: 1; - font-size: var(--font-size-3xs); - line-height: var(--font-line-height-compact); + font-size: var(--font-size--3xs); + line-height: var(--line-height--sm); } .closeButton { @@ -73,7 +73,7 @@ const onClick = () => { display: flex; align-items: center; justify-content: center; - height: var(--spacing-2xs); + height: var(--spacing--2xs); border: none; color: var(--color--text--tint-1); background-color: transparent; @@ -81,6 +81,6 @@ const onClick = () => { } .becomeCreatorButton { - margin: var(--spacing-s); + margin: var(--spacing--sm); } diff --git a/packages/frontend/editor-ui/src/components/BinaryDataDisplay.vue b/packages/frontend/editor-ui/src/components/BinaryDataDisplay.vue index 3e21cf341b8..24767fc0a0b 100644 --- a/packages/frontend/editor-ui/src/components/BinaryDataDisplay.vue +++ b/packages/frontend/editor-ui/src/components/BinaryDataDisplay.vue @@ -101,7 +101,7 @@ function closeWindow() { z-index: 10; width: 100%; height: 100%; - background-color: var(--color-run-data-background); + background-color: var(--run-data--color--background); overflow: hidden; text-align: center; diff --git a/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue b/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue index d82b7146ddf..e5ca02532fc 100644 --- a/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue +++ b/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue @@ -276,22 +276,22 @@ async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: bo } .input { - border-radius: var(--border-radius-base); + border-radius: var(--radius); } .input textarea { - font-size: var(--font-size-2xs); - padding-bottom: var(--spacing-2xl); + font-size: var(--font-size--2xs); + padding-bottom: var(--spacing--2xl); font-family: var(--font-family); resize: none; margin: 0; } .intro { - font-weight: var(--font-weight-bold); - font-size: var(--font-size-2xs); + font-weight: var(--font-weight--bold); + font-size: var(--font-size--2xs); color: var(--color--text--shade-1); - padding: var(--spacing-2xs) 0 0; + padding: var(--spacing--2xs) 0 0; } .inputContainer { position: relative; @@ -300,18 +300,18 @@ async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: bo display: flex; justify-content: space-between; position: absolute; - padding-bottom: var(--spacing-2xs); - padding-top: var(--spacing-2xs); + padding-bottom: var(--spacing--2xs); + padding-top: var(--spacing--2xs); bottom: 2px; - left: var(--spacing-xs); - right: var(--spacing-xs); - gap: var(--spacing-2xs); + left: var(--spacing--xs); + right: var(--spacing--xs); + gap: var(--spacing--2xs); align-items: end; z-index: 1; background-color: var(--color--foreground--tint-2); * { - font-size: var(--font-size-2xs); + font-size: var(--font-size--2xs); line-height: 1; } } @@ -320,7 +320,7 @@ async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: bo flex-shrink: 0; } .controls { - padding: var(--spacing-2xs) 0; + padding: var(--spacing--2xs) 0; display: flex; justify-content: flex-end; } @@ -329,7 +329,7 @@ async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: bo line-height: 1.2; } .droppable { - border: 1.5px dashed var(--color-ndv-droppable-parameter) !important; + border: 1.5px dashed var(--ndv--droppable-parameter--color) !important; } .activeDrop { border: 1.5px solid var(--color--success) !important; diff --git a/packages/frontend/editor-ui/src/components/ChatEmbedModal.vue b/packages/frontend/editor-ui/src/components/ChatEmbedModal.vue index 897176f4717..2fb6fc7a84d 100644 --- a/packages/frontend/editor-ui/src/components/ChatEmbedModal.vue +++ b/packages/frontend/editor-ui/src/components/ChatEmbedModal.vue @@ -199,7 +199,7 @@ function closeDialog() { diff --git a/packages/frontend/editor-ui/src/components/CollectionParameter.vue b/packages/frontend/editor-ui/src/components/CollectionParameter.vue index 9f2ef1f48fd..9d28b854317 100644 --- a/packages/frontend/editor-ui/src/components/CollectionParameter.vue +++ b/packages/frontend/editor-ui/src/components/CollectionParameter.vue @@ -219,28 +219,28 @@ function valueChanged(parameterData: IUpdateInformation) { diff --git a/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.test.ts b/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.test.ts index c62afcf04f1..90e4cff6f3e 100644 --- a/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.test.ts +++ b/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.test.ts @@ -1,54 +1,259 @@ import { createComponentRenderer } from '@/__tests__/render'; -import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue'; -import { createTestingPinia } from '@pinia/testing'; +import { retry } from '@/__tests__/utils'; +import { useInstallNode } from '@/composables/useInstallNode'; import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY } from '@/constants'; import { STORES } from '@n8n/stores'; +import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; -import { retry } from '@/__tests__/utils'; +import { vi, type MockedFunction } from 'vitest'; +import { ref } from 'vue'; +import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue'; -const renderComponent = createComponentRenderer(CommunityPackageInstallModal, { - data() { - return { - packageName: 'n8n-nodes-hello', - }; - }, - pinia: createTestingPinia({ - initialState: { - [STORES.UI]: { - modalsById: { - [COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: { open: true }, +vi.mock('@/composables/useInstallNode'); +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: () => ({ + track: vi.fn(), + pageEventQueue: [], + previousPath: '', + rudderStack: [], + init: vi.fn(), + identify: vi.fn(), + reset: vi.fn(), + page: vi.fn(), + trackEvent: vi.fn(), + }), +})); + +const mockInstallNode = vi.fn(); + +const mockUseInstallNode = useInstallNode as MockedFunction; + +const mockWindowOpen = vi.fn(); +Object.defineProperty(window, 'open', { + value: mockWindowOpen, + writable: true, +}); + +const renderComponent = (modalData = {}) => { + const renderer = createComponentRenderer(CommunityPackageInstallModal, { + pinia: createTestingPinia({ + initialState: { + [STORES.UI]: { + modalsById: { + [COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: { + open: true, + data: modalData, + }, + }, }, - }, - [STORES.SETTINGS]: { - settings: { - templates: { - host: '', + [STORES.SETTINGS]: { + settings: { + templates: { + host: '', + }, }, }, }, - }, - }), -}); + }), + }); + return renderer(); +}; describe('CommunityPackageInstallModal', () => { - it('should disable install button until user agrees', async () => { - const { getByTestId } = renderComponent(); + beforeEach(() => { + vi.clearAllMocks(); + mockUseInstallNode.mockReturnValue({ + installNode: mockInstallNode, + loading: ref(false), + }); + }); - await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + describe('Modal Rendering', () => { + it('should render the modal with correct title and structure', async () => { + const { getByTestId, getByText } = renderComponent(); - const packageNameInput = getByTestId('package-name-input'); - const installButton = getByTestId('install-community-package-button'); + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); - await userEvent.type(packageNameInput, 'n8n-nodes-test'); + expect(getByText('Install community nodes')).toBeInTheDocument(); + expect(getByTestId('package-name-input')).toBeInTheDocument(); + expect(getByTestId('user-agreement-checkbox')).toBeInTheDocument(); + expect(getByTestId('install-community-package-button')).toBeInTheDocument(); + }); + }); - expect(installButton).toBeDisabled(); + describe('Package Name Input', () => { + it('should initialize with provided package name', async () => { + const { getByTestId } = renderComponent({ packageName: 'n8n-nodes-custom' }); - await userEvent.click(getByTestId('user-agreement-checkbox')); + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); - expect(installButton).toBeEnabled(); + const packageNameInput = getByTestId('package-name-input'); + expect(packageNameInput).toHaveValue('n8n-nodes-custom'); + }); - await userEvent.click(getByTestId('user-agreement-checkbox')); + it('should clean npm commands on blur', async () => { + const { getByTestId } = renderComponent(); - expect(installButton).toBeDisabled(); + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const packageNameInput = getByTestId('package-name-input'); + + await userEvent.type(packageNameInput, 'npm install n8n-nodes-test'); + await userEvent.tab(); // Trigger blur + + expect(packageNameInput).toHaveValue('n8n-nodes-test'); + }); + + it('should clean npm i commands on blur', async () => { + const { getByTestId } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const packageNameInput = getByTestId('package-name-input'); + + await userEvent.type(packageNameInput, 'npm i n8n-nodes-test'); + await userEvent.tab(); // Trigger blur + + expect(packageNameInput).toHaveValue('n8n-nodes-test'); + }); + + it('should be disabled when loading', async () => { + mockUseInstallNode.mockReturnValue({ + installNode: mockInstallNode, + loading: ref(true), + }); + + const { getByTestId } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + expect(getByTestId('package-name-input')).toBeDisabled(); + }); + + it('should be disabled when disableInput is true', async () => { + const { getByTestId } = renderComponent({ disableInput: true }); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + expect(getByTestId('package-name-input')).toBeDisabled(); + }); + }); + + describe('Checkbox Agreement', () => { + it('should disable install button until user agrees', async () => { + const { getByTestId } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const packageNameInput = getByTestId('package-name-input'); + const installButton = getByTestId('install-community-package-button'); + + await userEvent.type(packageNameInput, 'n8n-nodes-test'); + + expect(installButton).toBeDisabled(); + + await userEvent.click(getByTestId('user-agreement-checkbox')); + + expect(installButton).toBeEnabled(); + + await userEvent.click(getByTestId('user-agreement-checkbox')); + + expect(installButton).toBeDisabled(); + }); + }); + + describe('Install Button', () => { + it('should be disabled when package name is empty', async () => { + const { getByTestId } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const installButton = getByTestId('install-community-package-button'); + expect(installButton).toBeDisabled(); + }); + + it('should be disabled when user has not agreed', async () => { + const { getByTestId } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const packageNameInput = getByTestId('package-name-input'); + const installButton = getByTestId('install-community-package-button'); + + await userEvent.type(packageNameInput, 'n8n-nodes-test'); + + expect(installButton).toBeDisabled(); + }); + + it('should be enabled when package name is provided and user agreed', async () => { + const { getByTestId } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const packageNameInput = getByTestId('package-name-input'); + const installButton = getByTestId('install-community-package-button'); + + await userEvent.type(packageNameInput, 'n8n-nodes-test'); + await userEvent.click(getByTestId('user-agreement-checkbox')); + + expect(installButton).toBeEnabled(); + }); + + it('should be disabled when loading', async () => { + mockUseInstallNode.mockReturnValue({ + installNode: mockInstallNode, + loading: ref(true), + }); + + const { getByTestId } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + expect(getByTestId('install-community-package-button')).toBeDisabled(); + }); + }); + + describe('Install Process', () => { + it('should call installNode with correct parameters on successful install', async () => { + mockInstallNode.mockResolvedValue({ success: true }); + + const { getByTestId } = renderComponent({ nodeType: 'n8n-nodes-test.TestNode' }); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const packageNameInput = getByTestId('package-name-input'); + const installButton = getByTestId('install-community-package-button'); + + await userEvent.type(packageNameInput, 'n8n-nodes-test'); + await userEvent.click(getByTestId('user-agreement-checkbox')); + await userEvent.click(installButton); + + expect(mockInstallNode).toHaveBeenCalledWith({ + type: 'unverified', + packageName: 'n8n-nodes-test', + nodeType: 'n8n-nodes-test.TestNode', + }); + }); + + it('should show error message on 400 error', async () => { + const errorMessage = 'Package not found'; + mockInstallNode.mockResolvedValue({ + success: false, + error: { httpStatusCode: 400, message: errorMessage }, + }); + + const { getByTestId, getByText } = renderComponent(); + + await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument()); + + const packageNameInput = getByTestId('package-name-input'); + const installButton = getByTestId('install-community-package-button'); + + await userEvent.type(packageNameInput, 'n8n-nodes-test'); + await userEvent.click(getByTestId('user-agreement-checkbox')); + await userEvent.click(installButton); + + await retry(() => expect(getByText(errorMessage)).toBeInTheDocument()); + }); }); }); diff --git a/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.vue b/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.vue index 083cfde747f..4f36f1b74ce 100644 --- a/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.vue +++ b/packages/frontend/editor-ui/src/components/CommunityPackageInstallModal.vue @@ -1,31 +1,38 @@ + + + + diff --git a/packages/frontend/editor-ui/src/components/ContactPromptModal.vue b/packages/frontend/editor-ui/src/components/ContactPromptModal.vue index c3314b07c68..0d84e105a57 100644 --- a/packages/frontend/editor-ui/src/components/ContactPromptModal.vue +++ b/packages/frontend/editor-ui/src/components/ContactPromptModal.vue @@ -111,11 +111,11 @@ const send = async () => { diff --git a/packages/frontend/editor-ui/src/components/CopyInput.vue b/packages/frontend/editor-ui/src/components/CopyInput.vue index 6ea643b0e50..2d0ccc80e8a 100644 --- a/packages/frontend/editor-ui/src/components/CopyInput.vue +++ b/packages/frontend/editor-ui/src/components/CopyInput.vue @@ -80,13 +80,13 @@ function copy() { overflow-wrap: break-word; } - padding: var(--spacing-xs); + padding: var(--spacing--xs); background-color: var(--color--background--light-2); - border: var(--border-base); - border-radius: var(--border-radius-base); + border: var(--border); + border-radius: var(--radius); cursor: pointer; position: relative; - font-weight: var(--font-weight-regular); + font-weight: var(--font-weight--regular); &:hover { --display-copy-button: flex; @@ -100,14 +100,14 @@ function copy() { .large { span { - font-size: var(--font-size-s); + font-size: var(--font-size--sm); line-height: 1.5; } } .medium { span { - font-size: var(--font-size-xs); + font-size: var(--font-size--xs); line-height: 1; } } @@ -122,11 +122,11 @@ function copy() { position: absolute; top: 0; right: 0; - padding: var(--spacing-xs); + padding: var(--spacing--xs); background-color: var(--color--background--light-2); height: 100%; align-items: center; - border-radius: var(--border-radius-base); + border-radius: var(--radius); span { font-family: unset; @@ -134,10 +134,10 @@ function copy() { } .hint { - margin-top: var(--spacing-2xs); - font-size: var(--font-size-2xs); - line-height: var(--font-line-height-loose); - font-weight: var(--font-weight-regular); + margin-top: var(--spacing--2xs); + font-size: var(--font-size--2xs); + line-height: var(--line-height--lg); + font-weight: var(--font-weight--regular); word-break: normal; } diff --git a/packages/frontend/editor-ui/src/components/CredentialCard.test.ts b/packages/frontend/editor-ui/src/components/CredentialCard.test.ts index bb041c252d1..02c17e45cba 100644 --- a/packages/frontend/editor-ui/src/components/CredentialCard.test.ts +++ b/packages/frontend/editor-ui/src/components/CredentialCard.test.ts @@ -5,8 +5,8 @@ import { createTestingPinia } from '@pinia/testing'; import { createComponentRenderer } from '@/__tests__/render'; import CredentialCard from '@/components/CredentialCard.vue'; import type { CredentialsResource } from '@/Interface'; -import type { ProjectSharingData } from '@/types/projects.types'; -import { useProjectsStore } from '@/stores/projects.store'; +import type { ProjectSharingData } from '@/features/projects/projects.types'; +import { useProjectsStore } from '@/features/projects/projects.store'; const renderComponent = createComponentRenderer(CredentialCard); diff --git a/packages/frontend/editor-ui/src/components/CredentialCard.vue b/packages/frontend/editor-ui/src/components/CredentialCard.vue index 2bfa6cbc86f..0867d4c2724 100644 --- a/packages/frontend/editor-ui/src/components/CredentialCard.vue +++ b/packages/frontend/editor-ui/src/components/CredentialCard.vue @@ -8,10 +8,10 @@ import { getResourcePermissions } from '@n8n/permissions'; import { useUIStore } from '@/stores/ui.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import TimeAgo from '@/components/TimeAgo.vue'; -import { useProjectsStore } from '@/stores/projects.store'; -import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue'; +import { useProjectsStore } from '@/features/projects/projects.store'; +import ProjectCardBadge from '@/features/projects/components/ProjectCardBadge.vue'; import { useI18n } from '@n8n/i18n'; -import { ResourceType } from '@/utils/projects.utils'; +import { ResourceType } from '@/features/projects/projects.utils'; import type { CredentialsResource } from '@/Interface'; import { N8nActionToggle, N8nBadge, N8nCard, N8nText } from '@n8n/design-system'; @@ -179,7 +179,7 @@ function moveResource() { diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index dcccaf0dc83..f64d0fe0405 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -34,14 +34,14 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useSettingsStore } from '@/stores/settings.store'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import type { Project, ProjectSharingData } from '@/types/projects.types'; +import type { Project, ProjectSharingData } from '@/features/projects/projects.types'; import { getResourcePermissions } from '@n8n/permissions'; import { assert } from '@n8n/utils/assert'; import { createEventBus } from '@n8n/utils/event-bus'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useTelemetry } from '@/composables/useTelemetry'; -import { useProjectsStore } from '@/stores/projects.store'; +import { useProjectsStore } from '@/features/projects/projects.store'; import { isExpression, isTestableExpression } from '@/utils/expressions'; import { getNodeAuthOptions, @@ -60,6 +60,7 @@ import { N8nText, type IMenuItem, } from '@n8n/design-system'; +import { injectWorkflowState } from '@/composables/useWorkflowState'; type Props = { modalName: string; @@ -74,6 +75,7 @@ const ndvStore = useNDVStore(); const settingsStore = useSettingsStore(); const uiStore = useUIStore(); const workflowsStore = useWorkflowsStore(); +const workflowState = injectWorkflowState(); const nodeTypesStore = useNodeTypesStore(); const projectsStore = useProjectsStore(); @@ -1040,7 +1042,7 @@ async function onAuthTypeChanged(type: string): Promise { uiStore.activeCredentialType = credentialsForType.name; resetCredentialData(); // Update current node auth type so credentials dropdown can be displayed properly - updateNodeAuthType(ndvStore.activeNode, type); + updateNodeAuthType(workflowState, ndvStore.activeNode, type); // Also update credential name but only if the default name is still used if (hasUnsavedChanges.value && !hasUserSpecifiedName.value) { const newDefaultName = await credentialsStore.getNewCredentialName({ @@ -1200,16 +1202,16 @@ const { width } = useElementSize(credNameRef); diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInfo.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInfo.vue index 64204a90a29..5e7bbe8500e 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInfo.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInfo.vue @@ -55,21 +55,21 @@ const i18n = useI18n(); diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInputs.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInputs.vue index 233c8a7b8f5..e7c56b5ca46 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInputs.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialInputs.vue @@ -64,7 +64,7 @@ function valueChanged(parameterData: IUpdateInformation) { diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 19a5382862e..06e68d3aaa6 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -1,18 +1,18 @@