mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
Merge remote-tracking branch 'origin/master' into ADO-4133
This commit is contained in:
commit
05dcbe8fbc
2
.github/workflows/check-pr-title.yml
vendored
2
.github/workflows/check-pr-title.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/ci-evals.yml
vendored
2
.github/workflows/ci-evals.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/ci-python.yml
vendored
2
.github/workflows/ci-python.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/docker-base-image.yml
vendored
2
.github/workflows/docker-base-image.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
6
.github/workflows/docker-build-push.yml
vendored
6
.github/workflows/docker-build-push.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
64
CHANGELOG.md
64
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, object>;
|
||||
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<string, object>;
|
||||
expect(Object.keys(pinData)).to.have.lengthOf(expectedPinnedDataKeys.length);
|
||||
expect(pinData).to.include.all.keys(expectedPinnedDataKeys);
|
||||
|
||||
const { runData } = interception.request.body as Record<string, object>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = '<h1>Test';
|
||||
const TEST_ELEMENT_P = '<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('<div>First</div>');
|
||||
ndv.actions.close();
|
||||
|
||||
workflowPage.actions.addNodeToCanvas('HTML', true, true, 'Generate HTML template');
|
||||
ndv.getters
|
||||
.htmlEditorContainer()
|
||||
.click()
|
||||
.find('.cm-content')
|
||||
.type('{selectall}')
|
||||
.paste('<div>Second</div>');
|
||||
ndv.actions.close();
|
||||
|
||||
workflowPage.actions.openNode('HTML');
|
||||
ndv.actions.clickFloatingNode('HTML1');
|
||||
ndv.getters
|
||||
.htmlEditorContainer()
|
||||
.find('.cm-content')
|
||||
.should('have.text', '<div>Second</div>');
|
||||
|
||||
ndv.actions.clickFloatingNode('HTML');
|
||||
ndv.getters.htmlEditorContainer().find('.cm-content').should('have.text', '<div>First</div>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.115.0",
|
||||
"version": "1.116.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.16",
|
||||
|
|
|
|||
|
|
@ -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<TestResult> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ async function main(): Promise<void> {
|
|||
: 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
export async function runLangsmithEvaluation(repetitions: number = 1): Promise<void> {
|
||||
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<void> {
|
|||
evaluators: [evaluator],
|
||||
maxConcurrency: 7,
|
||||
experimentPrefix: 'workflow-builder-evaluation',
|
||||
numRepetitions: repetitions,
|
||||
metadata: {
|
||||
evaluationType: 'llm-based',
|
||||
modelName: process.env.LLM_MODEL ?? 'default',
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UsageMetadata> = {
|
||||
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<UsageMetadata> = {
|
||||
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<UsageMetadata> = {
|
||||
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<UsageMetadata> = {
|
||||
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<UsageMetadata> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<UsageMetadata>): 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<UsageMetadata>): 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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -341,14 +341,18 @@ When modifying existing nodes:
|
|||
<handling_uncertainty>
|
||||
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:
|
|||
</response_patterns>
|
||||
`;
|
||||
|
||||
const currentWorkflowJson = `
|
||||
<current_workflow_json>
|
||||
{workflowJSON}
|
||||
</current_workflow_json>
|
||||
<trimmed_workflow_json_note>
|
||||
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.
|
||||
</trimmed_workflow_json_note>`;
|
||||
|
||||
const currentExecutionData = `
|
||||
<current_simplified_execution_data>
|
||||
{executionData}
|
||||
</current_simplified_execution_data>`;
|
||||
|
||||
const currentExecutionNodesSchemas = `
|
||||
<current_execution_nodes_schemas>
|
||||
{executionSchema}
|
||||
</current_execution_nodes_schemas>`;
|
||||
|
||||
const previousConversationSummary = `
|
||||
<previous_summary>
|
||||
{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',
|
||||
|
|
|
|||
|
|
@ -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: <tool_call: add_node> │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ [3] TOOL: "Node added successfully" │ │
|
||||
│ │ (no workflow context, no cache marker) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ [4] ASSISTANT: <tool_call: add_node> │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ [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: <tool_call: add_node> │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ [9] TOOL: "What about email validation?" │ │ ◄── ITERATION 5 (current)
|
||||
│ │ + <current_workflow_json> │ │
|
||||
│ │ + <current_execution_data> │ │
|
||||
│ │ + <current_execution_nodes_schemas> │ │
|
||||
│ │ 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!
|
||||
|
|
@ -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 = () => `
|
||||
<current_workflow_json>
|
||||
{"nodes": []}
|
||||
</current_workflow_json>
|
||||
<current_simplified_execution_data>
|
||||
{"data": "test"}
|
||||
</current_simplified_execution_data>
|
||||
<current_execution_nodes_schemas>
|
||||
[{"type": "test"}]
|
||||
</current_execution_nodes_schemas>`;
|
||||
|
||||
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 = '\n<workflow>test</workflow>';
|
||||
|
||||
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<workflow/>');
|
||||
|
||||
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<workflow/>');
|
||||
|
||||
// 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<workflow/>');
|
||||
|
||||
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<workflow/>');
|
||||
|
||||
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<workflow/>');
|
||||
|
||||
// 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 = '\n<workflow>new</workflow>';
|
||||
|
||||
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<workflow/>');
|
||||
|
||||
// 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 = '\n<workflow>current state</workflow>';
|
||||
|
||||
// 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</workflow>');
|
||||
expect(content2[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('should handle complete conversation lifecycle', () => {
|
||||
const workflowV1 = '\n<workflow>version 1</workflow>';
|
||||
const workflowV2 = '\n<workflow>version 2</workflow>';
|
||||
const workflowV3 = '\n<workflow>version 3</workflow>';
|
||||
|
||||
// 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
* - <current_workflow_json>...</current_workflow_json>
|
||||
* - <current_simplified_execution_data>...</current_simplified_execution_data>
|
||||
* - <current_execution_nodes_schemas>...</current_execution_nodes_schemas>
|
||||
*
|
||||
* 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*<current_workflow_json>[\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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
'',
|
||||
'<current_workflow_json>',
|
||||
JSON.stringify(trimmedWorkflow, null, 2),
|
||||
'</current_workflow_json>',
|
||||
'<trimmed_workflow_json_note>',
|
||||
'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.',
|
||||
'</trimmed_workflow_json_note>',
|
||||
'',
|
||||
'<current_simplified_execution_data>',
|
||||
JSON.stringify(executionData, null, 2),
|
||||
'</current_simplified_execution_data>',
|
||||
'',
|
||||
'<current_execution_nodes_schemas>',
|
||||
JSON.stringify(executionSchema, null, 2),
|
||||
'</current_execution_nodes_schemas>',
|
||||
].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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "0.49.0",
|
||||
"version": "0.50.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<ChatHubProvider, string> = {
|
|||
export const chatHubConversationModelSchema = z.object({
|
||||
provider: chatHubProviderSchema,
|
||||
model: z.string(),
|
||||
workflowId: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
export type ChatHubConversationModel = z.infer<typeof chatHubConversationModelSchema>;
|
||||
|
|
@ -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<string, ChatHubMessageDto>;
|
||||
rootIds: string[];
|
||||
activeMessageChain: string[];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class AiSessionMetadataResponseDto extends Z.class({
|
||||
hasMessages: z.boolean(),
|
||||
}) {}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "0.25.0",
|
||||
"version": "0.26.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
87
packages/@n8n/backend-test-utils/MIGRATION_TESTING.md
Normal file
87
packages/@n8n/backend-test-utils/MIGRATION_TESTING.md
Normal file
|
|
@ -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<void>`
|
||||
|
||||
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<void>`
|
||||
|
||||
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<void>`
|
||||
|
||||
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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
135
packages/@n8n/backend-test-utils/src/migration-test-helpers.ts
Normal file
135
packages/@n8n/backend-test-utils/src/migration-test-helpers.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -80,7 +80,9 @@ type EntityName =
|
|||
| 'InsightsByPeriod'
|
||||
| 'InsightsMetadata'
|
||||
| 'DataTable'
|
||||
| 'DataTableColumn';
|
||||
| 'DataTableColumn'
|
||||
| 'ChatHubSession'
|
||||
| 'ChatHubMessage';
|
||||
|
||||
/**
|
||||
* Truncate specific DB tables in a test DB.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.57.0",
|
||||
"version": "1.58.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "0.26.0",
|
||||
"version": "0.27.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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<IWorkflowBase, 'pinData'> & { pinData?: ISimplifiedPinData };
|
||||
|
||||
@PrimaryColumn({ transformer: idStringifier })
|
||||
executionId: string;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Pick<Role, 'slug' | 'displayName'>> = await runQuery(
|
||||
`SELECT ${slugColumn}, ${displayNameColumn} FROM ${tableName} ORDER BY ${displayNameColumn}, ${createdAtColumn} ASC`,
|
||||
);
|
||||
|
||||
// Group roles by displayName in memory
|
||||
const groupedByName = new Map<string, Array<Pick<Role, 'slug' | 'displayName'>>>();
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -448,7 +448,6 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||
}
|
||||
|
||||
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<ExecutionEntity> {
|
|||
}
|
||||
|
||||
if (Object.keys(executionData).length > 0) {
|
||||
// @ts-expect-error Fix typing
|
||||
await tx.update(ExecutionData, { executionId }, executionData);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "0.25.0",
|
||||
"version": "0.26.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
packages/@n8n/eslint-plugin-community-nodes/README.md
Normal file
60
packages/@n8n/eslint-plugin-community-nodes/README.md
Normal file
|
|
@ -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
|
||||
|
||||
<!-- begin auto-generated rules list -->
|
||||
|
||||
💼 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 | | ✅ ☑️ | | |
|
||||
|
||||
<!-- end auto-generated rules list -->
|
||||
|
|
@ -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`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Options
|
||||
|
||||
<!-- begin auto-generated rule options list -->
|
||||
|
||||
| Name | Description | Type |
|
||||
| :----------- | :----------------------------------------------------- | :------ |
|
||||
| `allowSlugs` | Whether to allow lowercase alphanumeric slugs with slashes | Boolean |
|
||||
| `allowUrls` | Whether to allow valid URLs | Boolean |
|
||||
|
||||
<!-- end auto-generated rule options list -->
|
||||
|
||||
## 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.
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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
|
||||
},
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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])];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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([]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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.
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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`
|
||||
|
|
@ -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`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## 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
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -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",
|
||||
"☑️"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Linter.Config>;
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | { name: string; testedBy?: string }>;
|
||||
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
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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};`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<keyof typeof messages> = [];
|
||||
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<keyof typeof messages> = [];
|
||||
|
||||
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<keyof typeof messages> = [];
|
||||
|
||||
// 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<keyof typeof messages> = [];
|
||||
|
||||
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<keyof typeof messages> = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, AnyRuleModule>;
|
||||
|
|
|
|||
|
|
@ -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 | { name: string; required?: boolean }> = [],
|
||||
): 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 | { name: string; required?: boolean }> = [],
|
||||
): 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: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<any> {
|
|||
{
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.helpers.httpRequest(options);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user