From bb8665b6d062ce4ce96fe5afd0a4222a2d15cacc Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:27:17 +0800 Subject: [PATCH 1/2] refactor(web): exercise real frontend behavior in tests --- oxlint-suppressions.json | 10 - .../base/notion-page-selector-flow.test.tsx | 16 +- web/__tests__/file-supported-formats.spec.tsx | 14 +- .../__tests__/external-service-sync.spec.tsx | 52 +- .../__tests__/layout-main.spec.tsx | 14 +- .../form/[token]/__tests__/form.spec.tsx | 105 +- .../set-password/__tests__/page.spec.tsx | 4 - .../__tests__/index.spec.tsx | 36 +- .../__tests__/dropdown-callbacks.spec.tsx | 14 +- .../dataset-info/__tests__/index.spec.tsx | 14 +- .../snippet-info/__tests__/dropdown.spec.tsx | 151 +- .../access-config/__tests__/index.spec.tsx | 112 +- .../app/annotation/__tests__/filter.spec.tsx | 154 +- .../app/annotation/__tests__/index.spec.tsx | 4 - .../environment-deployment-flow.spec.tsx | 20 +- .../app-publisher/__tests__/index.spec.tsx | 15 +- .../publish-with-multiple-model.spec.tsx | 4 - .../config-vision/__tests__/index.spec.tsx | 34 +- .../config/__tests__/config-audio.spec.tsx | 18 +- .../config/__tests__/config-document.spec.tsx | 18 +- .../config/__tests__/index.spec.tsx | 22 +- .../dataset-config/__tests__/index.spec.tsx | 129 +- .../select-dataset/__tests__/index.spec.tsx | 151 +- .../settings-modal/__tests__/index.spec.tsx | 25 +- .../debug/__tests__/chat-user-input.spec.tsx | 630 ++--- .../__tests__/chat-item.spec.tsx | 8 - .../__tests__/text-generation-item.spec.tsx | 44 +- .../__tests__/use-configuration-utils.spec.ts | 30 +- .../__tests__/use-configuration.spec.tsx | 68 +- .../create-app-modal/__tests__/index.spec.tsx | 9 - .../__tests__/index.spec.tsx | 5 - .../__tests__/archived-logs-notice.spec.tsx | 20 +- .../app/log/__tests__/filter.spec.tsx | 482 +--- .../app/log/__tests__/index.spec.tsx | 156 +- .../workflow-log/__tests__/detail.spec.tsx | 12 - .../app/workflow-log/__tests__/index.spec.tsx | 257 +- .../app/workflow-log/__tests__/list.spec.tsx | 12 - .../apps/__tests__/app-card.spec.tsx | 17 - .../apps/__tests__/creators-filter.spec.tsx | 24 +- .../components/apps/__tests__/list.spec.tsx | 516 ++-- .../use-workflow-online-users.spec.ts | 208 +- .../agent-log-modal/__tests__/index.spec.tsx | 41 +- .../__tests__/chat-wrapper.spec.tsx | 370 +-- .../base/chat/chat/__tests__/index.spec.tsx | 8 - .../chat/chat/__tests__/question.spec.tsx | 3 - .../base/chat/chat/__tests__/utils.spec.ts | 36 +- .../answer/__tests__/agent-content.spec.tsx | 31 +- .../chat/chat/answer/__tests__/index.spec.tsx | 5 - .../__tests__/expiration-time.spec.tsx | 51 +- .../chat/citation/__tests__/popup.spec.tsx | 61 +- .../__tests__/index.spec.tsx | 47 +- .../__tests__/config-param-modal.spec.tsx | 6 - .../annotation-reply/__tests__/index.spec.tsx | 6 - .../__tests__/modal.spec.tsx | 5 - .../file-upload/__tests__/index.spec.tsx | 17 +- .../__tests__/setting-content.spec.tsx | 6 - .../__tests__/setting-modal.spec.tsx | 17 +- .../image-upload/__tests__/index.spec.tsx | 17 +- .../moderation/__tests__/index.spec.tsx | 21 +- ...deration-setting-modal-submission.spec.tsx | 14 +- .../moderation-setting-modal.spec.tsx | 28 +- .../__tests__/param-config-content.spec.tsx | 99 +- .../file-uploader/__tests__/hooks.spec.ts | 1207 ++------ .../__tests__/file-item.spec.tsx | 6 +- .../__tests__/index.spec.tsx | 4 - .../__tests__/file-item.spec.tsx | 6 +- .../__tests__/file-list.spec.tsx | 4 - .../__tests__/index.spec.tsx | 11 - .../base/__tests__/base-form.spec.tsx | 86 +- .../field/__tests__/checkbox.spec.tsx | 34 - .../__tests__/field-integration.spec.tsx | 199 ++ .../field/__tests__/file-types.spec.tsx | 134 - .../field/__tests__/file-uploader.spec.tsx | 83 - .../field/__tests__/number-input.spec.tsx | 47 - .../field/__tests__/number-slider.spec.tsx | 44 - .../field/__tests__/options.spec.tsx | 39 - .../field/__tests__/select.spec.tsx | 67 - .../field/__tests__/text-area.spec.tsx | 52 - .../__tests__/index.spec.tsx | 46 - .../form/__tests__/actions.spec.tsx | 140 +- .../__tests__/use-get-form-values.spec.ts | 277 +- .../image-uploader/__tests__/hooks.spec.ts | 12 +- .../markdown-blocks/__tests__/button.spec.tsx | 13 - .../markdown-blocks/__tests__/link.spec.tsx | 17 - .../markdown-blocks/__tests__/music.spec.tsx | 20 - .../base/mermaid/__tests__/index.spec.tsx | 44 - .../__tests__/index.spec.tsx | 16 +- .../__tests__/base.spec.tsx | 20 +- .../prompt-editor/__tests__/utils.spec.ts | 4 - .../__tests__/input-field-keyboard.spec.tsx | 17 +- .../__tests__/component.spec.tsx | 97 +- .../prompt-log-modal/__tests__/index.spec.tsx | 29 +- .../__tests__/index.browser.spec.tsx | 39 + .../base/svg-gallery/__tests__/index.spec.tsx | 123 - .../base/zendesk/__tests__/index.spec.tsx | 2 +- .../custom-page/__tests__/index.spec.tsx | 7 - .../__tests__/use-web-app-brand.spec.tsx | 14 +- .../access-config/__tests__/index.spec.tsx | 99 +- .../document-picker/__tests__/index.spec.tsx | 51 +- .../__tests__/auto-disabled-document.spec.tsx | 15 +- .../__tests__/index-failed.spec.tsx | 387 +-- .../hooks/__tests__/use-upload.spec.tsx | 13 +- .../__tests__/index.spec.tsx | 158 +- .../__tests__/index.spec.tsx | 27 +- .../hooks/__tests__/use-dsl-import.spec.tsx | 27 +- .../list/__tests__/create-card.spec.tsx | 14 +- .../__tests__/edit-pipeline-info.spec.tsx | 71 +- .../template-card/__tests__/index.spec.tsx | 176 +- .../list/template-card/index.tsx | 16 +- .../datasets/create/__tests__/index.spec.tsx | 218 +- .../file-uploader/__tests__/index.spec.tsx | 24 - .../__tests__/file-list-item.spec.tsx | 3 - .../hooks/__tests__/use-file-upload.spec.tsx | 19 +- .../data-source-type-selector.spec.tsx | 11 - .../create/step-two/__tests__/index.spec.tsx | 90 +- .../__tests__/use-document-creation.spec.ts | 32 +- .../__tests__/use-indexing-config.spec.ts | 4 - .../__tests__/use-indexing-estimate.spec.ts | 79 +- .../create/website/__tests__/index.spec.tsx | 20 +- .../firecrawl/__tests__/index.spec.tsx | 24 +- .../jina-reader/__tests__/index.spec.tsx | 20 +- .../watercrawl/__tests__/index.spec.tsx | 20 +- .../documents/__tests__/index.spec.tsx | 297 +- .../documents/__tests__/status-filter.spec.ts | 13 - .../components/__tests__/list.spec.tsx | 9 - ...ocument-actions-with-query-client.spec.tsx | 145 +- .../__tests__/use-document-actions.spec.ts | 41 +- .../__tests__/file-list-item.spec.tsx | 3 - .../__tests__/use-local-file-upload.spec.tsx | 9 - .../online-documents/__tests__/index.spec.tsx | 26 +- .../online-drive/__tests__/index.spec.tsx | 18 +- .../file-list/__tests__/index.spec.tsx | 57 +- .../website-crawl/__tests__/index.spec.tsx | 26 +- .../base/options/__tests__/index.spec.tsx | 943 +------ .../__tests__/use-datasource-options.spec.ts | 19 +- .../online-document-preview.spec.tsx | 372 +-- .../preview/online-document-preview.tsx | 2 +- .../process-documents/__tests__/hooks.spec.ts | 91 +- .../__tests__/index.spec.tsx | 25 +- .../steps/__tests__/preview-panel.spec.tsx | 17 +- .../steps/__tests__/step-one-content.spec.tsx | 13 - .../documents/detail/__tests__/index.spec.tsx | 59 +- .../detail/__tests__/new-segment.spec.tsx | 81 +- .../__tests__/csv-uploader.spec.tsx | 32 +- .../detail/completed/__tests__/index.spec.tsx | 91 +- .../__tests__/new-child-segment.spec.tsx | 62 +- .../__tests__/use-child-segment-data.spec.ts | 873 ++---- .../__tests__/use-segment-list-data.spec.ts | 374 +-- .../__tests__/document-settings.spec.tsx | 53 +- .../__tests__/index.spec.tsx | 136 +- .../process-documents/__tests__/hooks.spec.ts | 109 +- .../use-document-list-query-state.spec.tsx | 13 - .../use-documents-page-state.spec.ts | 17 - .../status-item/__tests__/index.spec.tsx | 29 +- .../__tests__/RetrievalSettings.spec.tsx | 42 +- .../api-access/__tests__/card.spec.tsx | 17 +- .../flavours/__tests__/edit-slice.spec.tsx | 150 +- .../hit-testing/__tests__/index.spec.tsx | 362 +-- .../__tests__/modify-retrieval-modal.spec.tsx | 8 - .../components/query-input/index.tsx | 14 +- .../components/datasets/hit-testing/index.tsx | 4 +- .../datasets/list/__tests__/index.spec.tsx | 168 +- .../__tests__/use-dataset-card-state.spec.ts | 14 +- .../__tests__/modal.spec.tsx | 45 +- .../use-batch-edit-document-metadata.spec.ts | 17 +- .../use-edit-dataset-metadata.spec.ts | 61 +- .../__tests__/use-metadata-document.spec.ts | 44 +- .../dataset-metadata-drawer.spec.tsx | 24 +- .../__tests__/document-information.spec.tsx | 23 +- .../__tests__/index.spec.tsx | 24 +- .../__tests__/info-group.spec.tsx | 19 +- .../settings/form/__tests__/index.spec.tsx | 5 - .../hooks/__tests__/use-form-state.spec.ts | 15 +- .../explore/try-app/__tests__/index.spec.tsx | 663 ++--- .../try-app/app-info/__tests__/index.spec.tsx | 147 +- .../__tests__/use-get-requirements.spec.ts | 83 +- .../goto-anything/__tests__/index.spec.tsx | 266 +- .../actions/__tests__/plugin.spec.ts | 12 +- .../__tests__/compliance.spec.tsx | 16 +- .../account-dropdown/__tests__/index.spec.tsx | 27 +- .../account-setting/__tests__/index.spec.tsx | 7 - .../update-setting-dialog-form.spec.tsx | 35 +- .../__tests__/index.spec.tsx | 137 +- .../__tests__/modal.spec.tsx | 64 +- .../__tests__/selector.spec.tsx | 73 +- .../__tests__/card.spec.tsx | 17 +- .../members-page/__tests__/index.spec.tsx | 73 +- .../__tests__/member-menu.spec.tsx | 46 +- .../__tests__/role-selector.spec.tsx | 192 +- .../__tests__/hooks.spec.ts | 281 +- .../__tests__/index.non-cloud.spec.tsx | 7 - .../__tests__/index.spec.tsx | 20 +- .../__tests__/credential-selector.spec.tsx | 5 - ...itch-credential-in-load-balancing.spec.tsx | 14 +- .../__tests__/credential-item.spec.tsx | 18 +- .../hooks/__tests__/use-auth.spec.tsx | 32 +- .../__tests__/use-model-form-schemas.spec.tsx | 17 +- .../model-modal/__tests__/dialog.spec.tsx | 4 - .../model-modal/__tests__/index.spec.tsx | 505 +--- .../__tests__/index.spec.tsx | 62 +- .../__tests__/parameter-item.spec.tsx | 16 +- .../model-selector/__tests__/index.spec.tsx | 19 +- .../model-selector/__tests__/popup.spec.tsx | 45 +- .../__tests__/credential-panel.spec.tsx | 15 +- .../model-load-balancing-modal.spec.tsx | 61 +- .../__tests__/provider-card-actions.spec.tsx | 42 +- .../__tests__/use-trial-credits.spec.ts | 234 +- .../use-activate-credential.spec.tsx | 104 +- .../__tests__/index.spec.tsx | 121 +- .../permissions-page/__tests__/index.spec.tsx | 92 +- .../role-list/__tests__/row-menu.spec.tsx | 101 +- .../__tests__/index.spec.tsx | 7 - .../main-nav/__tests__/index.spec.tsx | 33 +- .../__tests__/workspace-card.spec.tsx | 27 +- .../use-plugins-with-latest-version.spec.ts | 86 +- .../plugins/card/__tests__/index.spec.tsx | 34 +- .../install-plugin/__tests__/utils.spec.ts | 11 +- .../base/__tests__/installed.spec.tsx | 20 +- .../__tests__/use-check-installed.spec.tsx | 134 +- .../item/__tests__/github-item.spec.tsx | 25 +- .../steps/__tests__/install.spec.tsx | 979 ++----- .../steps/__tests__/loaded.spec.tsx | 29 +- .../steps/__tests__/install.spec.tsx | 65 +- .../steps/__tests__/install.spec.tsx | 44 +- .../marketplace/__tests__/hooks.spec.tsx | 6 +- .../__tests__/use-silent-install.spec.ts | 27 +- .../embedded-marketplace-search.spec.tsx | 27 +- .../marketplace-live-search.spec.tsx | 9 - ...place-search-autocomplete.browser.spec.tsx | 4 - .../marketplace-search-autocomplete.spec.tsx | 16 +- .../authorized-in-data-source-node.spec.tsx | 11 - .../__tests__/authorized-in-node.spec.tsx | 2 - .../__tests__/plugin-auth-in-agent.spec.tsx | 2 - .../__tests__/plugin-auth.spec.tsx | 20 +- .../__tests__/add-oauth-button.spec.tsx | 8 - .../__tests__/api-key-modal.spec.tsx | 590 +--- .../__tests__/authorize-components.spec.tsx | 2105 +------------- .../authorize/__tests__/index.spec.tsx | 5 - .../__tests__/oauth-client-settings.spec.tsx | 495 +--- .../authorized/__tests__/index.spec.tsx | 13 - .../__tests__/app-inputs-panel.spec.tsx | 186 +- .../detail-header/__tests__/index.spec.tsx | 35 +- .../__tests__/llm-params-panel.spec.tsx | 265 +- .../__tests__/index.spec.tsx | 26 +- .../__tests__/delete-confirm.spec.tsx | 192 +- .../__tests__/index.spec.tsx | 9 - .../__tests__/list-view.spec.tsx | 4 - .../__tests__/selector-entry.spec.tsx | 4 - .../__tests__/selector-view.spec.tsx | 47 +- .../__tests__/subscription-card.spec.tsx | 15 - .../create/__tests__/common-modal.spec.tsx | 2419 ++--------------- .../create/__tests__/index.spec.tsx | 41 +- .../create/__tests__/oauth-client.spec.tsx | 1470 ++-------- .../use-common-modal-state.helpers.spec.ts | 9 +- .../__tests__/use-common-modal-state.spec.ts | 287 -- .../__tests__/use-oauth-client-state.spec.ts | 741 ----- .../edit/__tests__/index.spec.tsx | 1864 +------------ .../edit/apikey-edit-modal.tsx | 2 + .../tool-selector/__tests__/index.spec.tsx | 74 - .../use-plugin-installed-check.spec.ts | 148 +- .../hooks/__tests__/use-tool-selector.spec.ts | 87 +- .../__tests__/event-detail-drawer.spec.tsx | 14 - .../trigger/__tests__/event-list.spec.tsx | 4 - .../__tests__/index.spec.tsx | 9 - .../category-marketplace-panel.spec.tsx | 75 +- .../__tests__/plugins-panel.spec.tsx | 4 +- .../__tests__/use-reference-setting.spec.ts | 102 +- .../__tests__/category-filter.spec.tsx | 4 - ...nstallation-task-clearing.browser.spec.tsx | 2 +- .../__tests__/from-market-place.spec.tsx | 44 +- .../update-plugin/__tests__/index.spec.tsx | 26 +- .../rag-pipeline/__tests__/index.spec.tsx | 14 +- .../components/__tests__/conversion.spec.tsx | 54 +- .../__tests__/update-dsl-modal.spec.tsx | 25 +- .../__tests__/chunk-card.spec.tsx | 19 - .../rag-pipeline/components/conversion.tsx | 2 +- .../panel/input-field/__tests__/hooks.spec.ts | 4 - .../input-field/__tests__/index.spec.tsx | 1066 ++------ .../editor/__tests__/index.spec.tsx | 1167 +------- .../form/__tests__/hidden-fields.spec.tsx | 124 +- .../editor/form/__tests__/hooks.spec.ts | 65 +- .../editor/form/__tests__/index.spec.tsx | 16 +- .../form/__tests__/initial-fields.spec.tsx | 107 +- .../editor/form/__tests__/schema.spec.ts | 6 +- .../form/__tests__/show-all-settings.spec.tsx | 88 +- .../field-list/__tests__/hooks.spec.ts | 383 --- .../panel/input-field/field-list/hooks.ts | 21 +- .../__tests__/datasource.spec.tsx | 24 - .../preview/__tests__/data-source.spec.tsx | 82 - .../preview/__tests__/form.spec.tsx | 80 +- .../preview/__tests__/index.spec.tsx | 1061 +------- .../__tests__/process-documents.spec.tsx | 60 - .../preparation/__tests__/hooks.spec.ts | 30 +- .../preparation/__tests__/index.spec.tsx | 4 - .../__tests__/hooks.spec.ts | 78 +- .../__tests__/index.spec.tsx | 1366 +--------- .../__tests__/options.spec.tsx | 180 +- .../__tests__/run-mode.spec.tsx | 12 - .../publisher/__tests__/index.spec.tsx | 41 +- .../publisher/__tests__/popup.spec.tsx | 84 +- .../hooks/__tests__/use-DSL.spec.ts | 3 - .../use-available-nodes-meta-data.spec.ts | 46 - .../hooks/__tests__/use-file-settings.spec.ts | 5 - .../__tests__/use-input-field-panel.spec.ts | 129 - .../use-pipeline-refresh-draft.spec.ts | 13 +- .../__tests__/use-pipeline-template.spec.ts | 36 +- .../hooks/__tests__/use-pipeline.spec.ts | 415 +-- .../__tests__/use-update-dsl-modal.spec.ts | 25 +- .../utils/__tests__/index.spec.ts | 343 --- .../utils/__tests__/nodes.spec.ts | 67 +- .../__tests__/snippet-card.spec.tsx | 166 +- .../__tests__/snippet-create-button.spec.tsx | 52 +- .../import-snippet-dsl-dialog.spec.tsx | 42 +- .../snippets/__tests__/index.spec.tsx | 24 +- .../__tests__/snippet-main.spec.tsx | 22 +- .../hooks/__tests__/use-snippet-dsl.spec.ts | 27 +- .../__tests__/use-snippet-publish.spec.ts | 96 +- .../__tests__/use-create-snippet.spec.tsx | 35 +- .../hooks/__tests__/use-snippet-init.spec.ts | 434 +-- .../__tests__/index.spec.tsx | 8 - .../tools/mcp/__tests__/create-card.spec.tsx | 14 +- .../tools/mcp/__tests__/index.spec.tsx | 86 +- .../mcp/__tests__/mcp-server-modal.spec.tsx | 21 +- .../mcp/detail/__tests__/content.spec.tsx | 101 +- .../__tests__/use-mcp-modal-form.spec.ts | 35 +- .../tools/provider/__tests__/detail.spec.tsx | 4 - .../__tests__/config-credentials.spec.tsx | 10 - .../__tests__/use-configure-button.spec.ts | 62 +- .../workflow-app/__tests__/index.spec.tsx | 49 +- .../__tests__/provider-contract.spec.tsx | 20 +- .../__tests__/workflow-children.spec.tsx | 42 +- .../__tests__/workflow-panel.spec.tsx | 17 +- .../__tests__/chat-variable-trigger.spec.tsx | 33 +- .../__tests__/features-trigger.spec.tsx | 27 +- ...se-workflow-draft-graph-for-canvas.spec.ts | 27 - .../__tests__/use-workflow-template.spec.ts | 71 +- .../__tests__/candidate-node-main.spec.tsx | 55 +- .../workflow/__tests__/custom-edge.spec.tsx | 270 +- .../__tests__/edge-contextmenu.spec.tsx | 9 - .../workflow/__tests__/features.spec.tsx | 12 +- .../__tests__/panel-contextmenu.spec.tsx | 22 +- .../__tests__/reactflow-mock-state.ts | 20 +- .../__tests__/trigger-status-sync.spec.tsx | 347 --- .../__tests__/workflow-edge-events.spec.tsx | 55 +- .../workflow/__tests__/workflow-test-env.tsx | 43 +- .../block-selector/__tests__/index.spec.tsx | 20 +- .../block-selector/__tests__/tabs.spec.tsx | 29 +- .../__tests__/tool-picker.spec.tsx | 118 +- .../snippets/__tests__/index.spec.tsx | 8 - .../header/__tests__/running-title.spec.tsx | 19 +- .../header/__tests__/view-history.spec.tsx | 26 +- .../hooks/__tests__/use-checklist.spec.ts | 99 +- .../hooks/__tests__/use-config-vision.spec.ts | 136 +- .../use-dynamic-test-run-options.spec.tsx | 206 +- .../use-edges-interactions.helpers.spec.ts | 25 +- .../__tests__/use-edges-interactions.spec.ts | 4 - .../use-fetch-workflow-inspect-vars.spec.ts | 106 +- .../use-inspect-vars-crud-common.spec.ts | 29 +- .../use-node-plugin-installation.spec.ts | 77 +- .../use-nodes-available-var-list.spec.ts | 13 +- .../__tests__/use-nodes-interactions.spec.ts | 133 +- .../__tests__/use-nodes-meta-data.spec.tsx | 56 +- .../hooks/__tests__/use-tool-icon.spec.ts | 4 - .../__tests__/use-workflow-search.spec.tsx | 36 +- .../__tests__/use-workflow-update.spec.tsx | 35 +- .../__tests__/use-workflow-variables.spec.ts | 253 +- .../__tests__/use-workflow-agent-log.spec.ts | 4 - .../__tests__/use-workflow-run-event.spec.ts | 115 - .../__tests__/details.spec.tsx | 22 +- .../nodes/__tests__/render-node-config.ts | 61 + .../__tests__/use-config-test-utils.spec.ts | 24 - .../add-variable-popup-with-position.spec.tsx | 11 +- .../form-input-item.branches.spec.tsx | 8 +- .../__tests__/form-input-item.spec.tsx | 8 +- .../__tests__/node-control.spec.tsx | 16 +- .../components/__tests__/node-handle.spec.tsx | 49 +- .../var-reference-picker.branches.spec.tsx | 8 +- .../__tests__/var-reference-picker.spec.tsx | 8 +- .../workflow-panel/__tests__/index.spec.tsx | 119 +- .../__tests__/use-available-var-list.spec.ts | 9 +- .../hooks/__tests__/use-one-step-run.spec.ts | 109 +- .../__tests__/use-output-var-list.spec.ts | 6 - .../nodes/agent-v2/__tests__/panel.spec.tsx | 118 +- ...ave-inline-agent-to-roster-dialog.spec.tsx | 214 +- .../agent/__tests__/integration.spec.tsx | 162 +- .../nodes/agent/__tests__/use-config.spec.ts | 89 +- .../components/__tests__/model-bar.spec.tsx | 7 - .../components/__tests__/tool-icon.spec.tsx | 59 +- .../nodes/answer/__tests__/panel.spec.tsx | 96 - .../nodes/answer/__tests__/use-config.spec.ts | 95 +- .../assigner/__tests__/integration.spec.tsx | 16 - .../nodes/assigner/__tests__/panel.spec.tsx | 122 - .../assigner/__tests__/use-config.spec.tsx | 21 +- .../use-single-run-form-params.spec.ts | 14 +- .../var-list/__tests__/index.spec.tsx | 8 +- .../nodes/code/__tests__/use-config.spec.ts | 382 +-- .../use-single-run-form-params.spec.ts | 14 +- .../__tests__/integration.spec.tsx | 209 -- .../__tests__/panel.spec.tsx | 15 - .../__tests__/use-config.spec.ts | 145 +- .../nodes/end/__tests__/node.spec.tsx | 33 +- .../nodes/end/__tests__/use-config.spec.ts | 89 +- .../nodes/http/__tests__/use-config.spec.ts | 375 +-- .../http/components/timeout/index.spec.tsx | 13 +- .../__tests__/human-input.spec.tsx | 38 +- .../__tests__/form-content-preview.spec.tsx | 6 - .../__tests__/form-content.spec.tsx | 14 +- .../components/__tests__/user-action.spec.tsx | 95 +- .../hooks/__tests__/use-config.spec.ts | 287 +- .../hooks/__tests__/use-form-content.spec.ts | 151 - .../use-single-run-form-params.spec.ts | 11 +- .../if-else/__tests__/integration.spec.tsx | 20 +- .../if-else/__tests__/use-config.spec.tsx | 320 +-- .../iteration-start/__tests__/index.spec.tsx | 23 +- .../iteration/__tests__/integration.spec.tsx | 10 +- .../iteration/__tests__/use-config.spec.ts | 343 +-- .../__tests__/use-interactions.spec.tsx | 34 +- .../use-single-run-form-params.spec.ts | 44 +- .../knowledge-base/__tests__/node.spec.tsx | 10 +- .../knowledge-base/__tests__/panel.spec.tsx | 8 - .../__tests__/integration.spec.tsx | 80 +- .../__tests__/use-config.spec.ts | 146 +- .../use-knowledge-dataset-selection.spec.ts | 6 +- .../hooks/use-knowledge-dataset-selection.ts | 6 +- .../__tests__/integration.spec.tsx | 46 - .../list-operator/__tests__/node.spec.tsx | 10 +- .../list-operator/__tests__/panel.spec.tsx | 275 -- .../__tests__/use-config.spec.tsx | 274 +- .../__tests__/limit-config.spec.tsx | 18 - .../nodes/llm/__tests__/panel.spec.tsx | 4 - .../nodes/llm/__tests__/use-config.spec.ts | 599 +--- .../use-single-run-form-params.spec.ts | 49 +- .../__tests__/json-importer.spec.tsx | 260 +- .../nodes/loop-start/__tests__/index.spec.tsx | 23 +- .../nodes/loop/__tests__/use-config.spec.tsx | 329 +-- .../loop/__tests__/use-interactions.spec.tsx | 33 +- ...use-single-run-form-params.helpers.spec.ts | 52 +- .../use-single-run-form-params.spec.ts | 61 +- .../__tests__/integration.spec.tsx | 814 ++---- .../__tests__/panel.spec.tsx | 39 +- .../use-single-run-form-params.spec.ts | 14 +- .../__tests__/import-from-tool.spec.tsx | 20 +- .../__tests__/integration.spec.tsx | 154 -- .../__tests__/panel.spec.tsx | 166 -- .../__tests__/use-config.spec.ts | 302 +- .../use-single-run-form-params.spec.ts | 14 +- .../nodes/start/__tests__/use-config.spec.ts | 30 +- .../use-single-run-form-params.spec.ts | 37 +- .../__tests__/integration.spec.tsx | 241 -- .../__tests__/panel.spec.tsx | 251 +- .../__tests__/use-config.spec.ts | 162 +- .../use-single-run-form-params.spec.ts | 14 +- .../nodes/tool/__tests__/panel.spec.tsx | 42 +- .../tool/hooks/__tests__/use-config.spec.tsx | 29 +- .../use-current-tool-collection.spec.tsx | 9 - .../use-get-data-for-check-more.spec.ts | 51 - .../use-single-run-form-params.spec.ts | 22 +- .../trigger-schedule/__tests__/panel.spec.tsx | 16 - .../__tests__/use-config.spec.ts | 195 +- .../trigger-webhook/__tests__/panel.spec.tsx | 20 - .../__tests__/use-config.spec.tsx | 301 +- .../variable-assigner/__tests__/hooks.spec.ts | 19 +- .../__tests__/use-config.spec.tsx | 346 +-- .../operator/__tests__/add-block.spec.tsx | 79 +- .../__tests__/index.spec.tsx | 63 +- .../panel/env-panel/__tests__/index.spec.tsx | 61 +- .../__tests__/index.spec.tsx | 20 +- .../__tests__/index.spec.tsx | 36 +- .../action-menu/__tests__/index.spec.tsx | 7 - .../workflow/utils/__tests__/tool.spec.ts | 18 +- .../utils/__tests__/workflow-init.spec.ts | 31 +- .../value-content.helpers.branches.spec.ts | 47 - .../__tests__/value-content.helpers.spec.ts | 30 + .../__tests__/value-content.spec.tsx | 16 +- .../__tests__/example-prompts.spec.tsx | 16 - .../components/__tests__/custom-edge.spec.tsx | 92 - .../components/nodes/__tests__/base.spec.tsx | 72 - .../device/__tests__/page-terminal.spec.tsx | 4 - .../__tests__/institution-field.spec.tsx | 166 +- .../set-password/__tests__/page.spec.tsx | 27 +- .../signin/__tests__/one-more-step.spec.tsx | 27 +- .../signin/check-code/__tests__/page.spec.tsx | 6 +- .../signup/check-code/__tests__/page.spec.tsx | 36 +- web/app/signup/components/input-mail.spec.tsx | 31 +- .../set-password/__tests__/page.spec.tsx | 27 +- web/context/i18n.spec.ts | 12 +- .../configure/__tests__/page.spec.tsx | 2326 +++++----------- .../__tests__/agent-prompt-editor.spec.tsx | 49 +- .../model-config/__tests__/field.spec.tsx | 11 +- .../tools/__tests__/index.spec.tsx | 44 +- .../preview/__tests__/versions-panel.spec.tsx | 104 +- .../use-agent-configure-build-draft.ts | 4 +- .../agent-v2/roster/__tests__/page.spec.tsx | 191 +- .../__tests__/create-agent-dialog.spec.tsx | 95 +- .../__tests__/delete-agent-dialog.spec.tsx | 35 +- .../__tests__/duplicate-agent-dialog.spec.tsx | 79 +- .../__tests__/edit-agent-dialog.spec.tsx | 199 +- web/features/skills/__tests__/page.spec.tsx | 61 +- .../tag-management/__tests__/render.tsx | 23 + .../__tests__/skill-card-tags.spec.tsx | 19 +- .../__tests__/tag-filter.spec.tsx | 9 +- .../__tests__/tag-item-editor.spec.tsx | 109 +- .../__tests__/tag-management-modal.spec.tsx | 70 +- .../__tests__/tag-selector.spec.tsx | 119 +- web/test/console/service-responses.ts | 61 + web/test/console/tool-provider-fixture.ts | 15 + web/test/console/tool-providers.ts | 25 + web/test/debug-configuration.tsx | 18 + web/test/deferred.ts | 10 + web/test/fixtures/plugin.ts | 65 + web/test/nuqs-testing.tsx | 16 + web/test/pipeline-variables.ts | 18 + 512 files changed, 14068 insertions(+), 42560 deletions(-) delete mode 100644 web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx create mode 100644 web/app/components/base/form/components/field/__tests__/field-integration.spec.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/file-types.spec.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/number-input.spec.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/options.spec.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/select.spec.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/text-area.spec.tsx delete mode 100644 web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx create mode 100644 web/app/components/base/svg-gallery/__tests__/index.browser.spec.tsx delete mode 100644 web/app/components/base/svg-gallery/__tests__/index.spec.tsx delete mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts delete mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts delete mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts delete mode 100644 web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx delete mode 100644 web/app/components/rag-pipeline/hooks/__tests__/use-input-field-panel.spec.ts delete mode 100644 web/app/components/rag-pipeline/utils/__tests__/index.spec.ts delete mode 100644 web/app/components/workflow/__tests__/trigger-status-sync.spec.tsx delete mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts create mode 100644 web/app/components/workflow/nodes/__tests__/render-node-config.ts delete mode 100644 web/app/components/workflow/nodes/__tests__/use-config-test-utils.spec.ts delete mode 100644 web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx delete mode 100644 web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx delete mode 100644 web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx delete mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts delete mode 100644 web/app/components/workflow/nodes/list-operator/__tests__/panel.spec.tsx delete mode 100644 web/app/components/workflow/nodes/question-classifier/__tests__/panel.spec.tsx delete mode 100644 web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx delete mode 100644 web/app/components/workflow/nodes/tool/hooks/__tests__/use-get-data-for-check-more.spec.ts delete mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts delete mode 100644 web/app/components/workflow/workflow-preview/components/__tests__/custom-edge.spec.tsx delete mode 100644 web/app/components/workflow/workflow-preview/components/nodes/__tests__/base.spec.tsx create mode 100644 web/features/tag-management/__tests__/render.tsx create mode 100644 web/test/console/service-responses.ts create mode 100644 web/test/console/tool-provider-fixture.ts create mode 100644 web/test/console/tool-providers.ts create mode 100644 web/test/debug-configuration.tsx create mode 100644 web/test/deferred.ts create mode 100644 web/test/fixtures/plugin.ts create mode 100644 web/test/pipeline-variables.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 8e8dc1f1015a06..995e07092cc2fd 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1752,16 +1752,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx": { - "eslint-react/static-components": { - "count": 4 - } - }, - "web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx": { - "eslint-react/static-components": { - "count": 4 - } - }, "web/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields.tsx": { "eslint-react/static-components": { "count": 2 diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index f69ceaf4eb1dfe..3cba9251c100f6 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -1,14 +1,14 @@ import type { DatasourceCredentialResponse } from '@dify/contracts/api/console/auth/types.gen' import type { DataSourceNotionWorkspace } from '@/models/common' -import { render, screen, waitFor } from '@testing-library/react' +import { render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import NotionPageSelector from '@/app/components/base/notion-page-selector/base' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import { consoleQuery } from '@/service/console' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' const mockInvalidPreImportNotionPages = vi.fn() -const mockSetSettingsDestination = vi.fn() const mockUsePreImportNotionPages = vi.fn() vi.mock('@tanstack/react-virtual', () => ({ @@ -43,11 +43,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } }) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - const buildCredential = ( id: string, name: string, @@ -122,6 +117,9 @@ const workspacePagesByCredential: Record = ], } +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('Base Notion Page Selector Flow', () => { beforeEach(() => { vi.clearAllMocks() @@ -209,6 +207,8 @@ describe('Base Notion Page Selector Flow', () => { await user.click( screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }), ) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) }) diff --git a/web/__tests__/file-supported-formats.spec.tsx b/web/__tests__/file-supported-formats.spec.tsx index 2fe8d812d92314..6d8a9bf6fd9fa4 100644 --- a/web/__tests__/file-supported-formats.spec.tsx +++ b/web/__tests__/file-supported-formats.spec.tsx @@ -8,15 +8,7 @@ import { BlockEnum } from '@/app/components/workflow/types' import { LanguagesSupported } from '@/i18n/language' import { consoleQuery } from '@/service/console' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'files.upload.get': () => ({ - data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 }, - }), - }) -}) +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' let mockLocale = 'en-US' const request = vi.hoisted(() => vi.fn<(url: string) => Promise>()) @@ -74,6 +66,10 @@ beforeEach(() => { describe('file supported formats across upload and document extraction', () => { it('shares the raw supported-formats response with the uploader across locale changes', async () => { const queryClient = createConsoleQueryClient() + queryClient.setQueryData( + consoleQuery.files.upload.get.queryKey(), + createFileUploadConfigFixture({ file_upload_limit: 10 }), + ) const response = { allowed_extensions: ['PDF', 'pdf', 'md', 'md', 'DOCX', 'docx'] } request.mockImplementation(async () => Response.json(response)) const consumers = () => ( diff --git a/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx b/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx index c851acaf03be80..839578fa24dfd5 100644 --- a/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx +++ b/web/app/(commonLayout)/__tests__/external-service-sync.spec.tsx @@ -1,6 +1,8 @@ -import { render, waitFor } from '@testing-library/react' +import type { ReactElement } from 'react' +import { waitFor } from '@testing-library/react' import { REGISTRATION_SUCCESS_STORAGE_KEY } from '@/app/components/base/amplitude/registration-session-state' import { rememberRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' +import { renderWithConsoleQuery } from '@/test/console/query-data' import { AmplitudeIdentitySync } from '../external-service-sync' const { mockSetUserId, mockSetUserProperties, mockTrackEvent } = vi.hoisted(() => ({ @@ -11,38 +13,6 @@ const { mockSetUserId, mockSetUserProperties, mockTrackEvent } = vi.hoisted(() = })), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - useSuspenseQuery: () => ({ - data: { - id: 'account-id', - email: 'person@example.com', - name: 'Person', - is_password_set: true, - }, - }), - } -}) - -vi.mock('jotai', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - useAtomValue: () => ({ - id: 'workspace-id', - name: 'Workspace', - plan: 'professional', - role: 'owner', - }), - } -}) - -vi.mock('@/features/account-profile/client', () => ({ - userProfileQueryOptions: () => ({}), -})) - vi.mock('@/app/components/base/amplitude', () => ({ setUserId: (...args: unknown[]) => mockSetUserId(...args), setUserProperties: (...args: unknown[]) => mockSetUserProperties(...args), @@ -65,6 +35,22 @@ vi.mock('@/app/components/base/analytics-consent/consent-store', async (importOr } }) +const render = (ui: ReactElement) => + renderWithConsoleQuery(ui, { + accountProfile: { + id: 'account-id', + email: 'person@example.com', + name: 'Person', + is_password_set: true, + }, + currentWorkspace: { + id: 'workspace-id', + name: 'Workspace', + plan: 'professional', + role: 'owner', + }, + }) + describe('AmplitudeIdentitySync', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index ece92fc09d44e5..0f45cb30a380de 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -1,6 +1,7 @@ import { screen, waitFor } from '@testing-library/react' import { createHttpError } from '@/service/http-error' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { renderWithConsoleQuery as renderWithConsoleQueryWithoutUrlState } from '@/test/console/query-data' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { DatasetACLPermission } from '@/utils/permission' import CreateDocumentsPage from '../documents/create/page' import DatasetDetailLayout from '../layout-main' @@ -12,6 +13,12 @@ const mockNavigation = vi.hoisted(() => ({ useRouter: vi.fn(), })) +const onUrlUpdate = vi.fn() +const renderWithConsoleQuery = createNuqsTestRenderer( + renderWithConsoleQueryWithoutUrlState, + () => ({ onUrlUpdate }), +) + const render = (ui: Parameters[0]) => renderWithConsoleQuery(ui, { systemFeatures: { @@ -23,11 +30,6 @@ vi.mock('@/next/navigation', () => mockNavigation) const { mockDatasetDetailQuery } = vi.hoisted(() => ({ mockDatasetDetailQuery: vi.fn() })) -vi.mock('nuqs', async (importOriginal) => ({ - ...(await importOriginal()), - useQueryState: () => [null, vi.fn()], -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useDefaultModel: () => ({ data: undefined }), })) diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx index 10d415ff876a2f..967e4e75c89c94 100644 --- a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx @@ -1,11 +1,15 @@ import type { FormData } from '../form' import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' -import { act, render, renderHook, screen } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' import useDocumentTitle from '@/hooks/use-document-title' import { createHttpError } from '@/service/http-error' +import { + renderWithConsoleQuery as render, + renderHookWithConsoleQuery as renderHook, +} from '@/test/console/query-data' import { TransferMethod } from '@/types/app' import FormContent from '../form' import { useFormSubmit } from '../use-form-submit' @@ -40,12 +44,13 @@ vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: 'token-123' }), })) -vi.mock('@/service/use-share', () => ({ +vi.mock('@/service/use-share', async (importOriginal) => ({ + ...(await importOriginal()), useGetHumanInputForm: (...args: unknown[]) => mockUseGetHumanInputForm(...args), - useSubmitHumanInputForm: () => ({ - mutate: mockSubmitForm, - isPending: false, - }), +})) +vi.mock('@/service/share', async (importOriginal) => ({ + ...(await importOriginal()), + submitHumanInputForm: mockSubmitForm, })) vi.mock('@/hooks/use-document-title', () => ({ @@ -182,6 +187,7 @@ describe('Human input share form', () => { beforeEach(() => { vi.clearAllMocks() + mockSubmitForm.mockResolvedValue({}) mockContentItemState.staleAttachmentInputChange = undefined mockUseGetHumanInputForm.mockReturnValue({ data: formData, @@ -281,26 +287,20 @@ describe('Human input share form', () => { await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) await user.click(screen.getByRole('button', { name: 'Approve' })) - expect(mockSubmitForm).toHaveBeenCalledWith( - { - token: 'token-123', - data: { - action: 'approve', - inputs: { - summary: 'updated summary', - attachments: [ - { - type: 'document', - transfer_method: TransferMethod.local_file, - url: '', - upload_file_id: 'upload-file-1', - }, - ], - }, + await waitFor(() => + expect(mockSubmitForm).toHaveBeenCalledWith('token-123', { + action: 'approve', + inputs: { + summary: 'updated summary', + attachments: [ + { + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }, + ], }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), }), ) }) @@ -314,17 +314,12 @@ describe('Human input share form', () => { await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) await user.click(screen.getByRole('button', { name: 'Approve' })) - const options = mockSubmitForm.mock.calls[0]![1] as { onSuccess: () => void } - act(() => { - options.onSuccess() - }) - - expect(screen.getByText('share.humanInput.thanks')).toBeInTheDocument() + expect(await screen.findByText('share.humanInput.thanks')).toBeInTheDocument() expect(screen.getByText('share.humanInput.recorded')).toBeInTheDocument() expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument() }) - it('should submit empty inputs when there are no form values to process', () => { + it('should submit empty inputs when there are no form values to process', async () => { const { result } = renderHook(() => useFormSubmit('token-empty')) act(() => { @@ -335,16 +330,10 @@ describe('Human input share form', () => { ) }) - expect(mockSubmitForm).toHaveBeenCalledWith( - { - token: 'token-empty', - data: { - action: 'reject', - inputs: {}, - }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), + await waitFor(() => + expect(mockSubmitForm).toHaveBeenCalledWith('token-empty', { + action: 'reject', + inputs: {}, }), ) }) @@ -357,26 +346,20 @@ describe('Human input share form', () => { await user.click(screen.getByRole('button', { name: 'share-update-attachments' })) await user.click(screen.getByRole('button', { name: 'Approve' })) - expect(mockSubmitForm).toHaveBeenCalledWith( - { - token: 'token-123', - data: { - action: 'approve', - inputs: { - summary: 'initial summary', - attachments: [ - { - type: 'document', - transfer_method: TransferMethod.local_file, - url: '', - upload_file_id: 'upload-file-1', - }, - ], - }, + await waitFor(() => + expect(mockSubmitForm).toHaveBeenCalledWith('token-123', { + action: 'approve', + inputs: { + summary: 'initial summary', + attachments: [ + { + type: 'document', + transfer_method: TransferMethod.local_file, + url: '', + upload_file_id: 'upload-file-1', + }, + ], }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), }), ) }) diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/__tests__/page.spec.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/__tests__/page.spec.tsx index 5623a2a37c6388..3abd4c27a02478 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/__tests__/page.spec.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/__tests__/page.spec.tsx @@ -4,10 +4,6 @@ import { useRouter, useSearchParams } from '@/next/navigation' import { changeWebAppPasswordWithToken } from '@/service/common' import ChangePasswordForm from '../page' -vi.mock('ahooks', () => ({ - useCountDown: () => [5000], -})) - vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), useSearchParams: vi.fn(), diff --git a/web/app/components/access-rules-editor/__tests__/index.spec.tsx b/web/app/components/access-rules-editor/__tests__/index.spec.tsx index 6dfb91a9745351..3ab698e4598242 100644 --- a/web/app/components/access-rules-editor/__tests__/index.spec.tsx +++ b/web/app/components/access-rules-editor/__tests__/index.spec.tsx @@ -1,20 +1,15 @@ import type { AccountWithRoleResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { AccessPolicyWithBindings, ResourceUserAccessSetting } from '@/models/access-control' -import { fireEvent, render, screen, within } from '@testing-library/react' +import { fireEvent, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import AccessRulesEditor from '../index' const mockMembers = vi.hoisted(() => ({ accounts: [] as AccountWithRoleResponse[] | null, - isLoading: false, })) const mockMembersQuery = vi.hoisted(() => vi.fn()) -vi.mock('@tanstack/react-query', async (importOriginal) => ({ - ...(await importOriginal()), - useQuery: mockMembersQuery, -})) - const createRule = (resourceType: 'app' | 'dataset'): AccessPolicyWithBindings => ({ policy: { id: `${resourceType}-policy-id`, @@ -89,11 +84,7 @@ describe('AccessRulesEditor', () => { beforeEach(() => { vi.clearAllMocks() mockMembers.accounts = [] - mockMembers.isLoading = false - mockMembersQuery.mockImplementation(() => ({ - data: { accounts: mockMembers.accounts }, - isPending: mockMembers.isLoading, - })) + mockMembersQuery.mockImplementation(() => Promise.resolve({ accounts: mockMembers.accounts })) }) it('should render loading state before empty or row content', () => { @@ -521,7 +512,7 @@ describe('AccessRulesEditor', () => { const dialog = await screen.findByRole('dialog', { name: 'permission.accessRule.addMembersTitle', }) - expect(within(dialog).getByText('Evan')).toBeInTheDocument() + expect(await within(dialog).findByText('Evan')).toBeInTheDocument() const evanItem = within(dialog).getByRole('menuitem', { name: /Evan/ }) expect(evanItem).toHaveAttribute('aria-disabled', 'true') expect(evanItem).toHaveTextContent('common.operation.added') @@ -578,11 +569,7 @@ describe('AccessRulesEditor', () => { it('should announce a failed member load without offering candidates', async () => { const user = userEvent.setup() - mockMembersQuery.mockImplementation(() => ({ - data: undefined, - isPending: false, - error: new Error('Request failed'), - })) + mockMembersQuery.mockRejectedValue(new Error('Request failed')) render( { name: 'permission.accessRule.addMembersTitle', }) - expect(within(dialog).getByRole('alert')).toHaveTextContent('common.api.actionFailed') + expect(await within(dialog).findByRole('alert')).toHaveTextContent('common.api.actionFailed') expect(within(dialog).queryByRole('menuitem')).not.toBeInTheDocument() expect( within(dialog).queryByText('permission.accessRule.noAvailableMembers'), @@ -699,3 +686,14 @@ describe('AccessRulesEditor', () => { expect(screen.queryByText('permission.accessRule.selected')).not.toBeInTheDocument() }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.members.get': (input) => mockMembersQuery(input), + }), + } +}) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index b3dd2b7fc6b26e..2f694de2a8b5e3 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -114,11 +114,15 @@ vi.mock('@/context/permission-state', async () => { return createPermissionStateModuleMock(() => mockConsoleState.current) }) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.byPipelineId.exports.get': () => ({ mutateAsync: mockExportPipeline }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.exports.get': (input) => mockExportPipeline(input), + }), + } }) vi.mock('@/service/datasets', () => ({ diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index 0b6de81589cd8c..8508c578ecb925 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -118,13 +118,15 @@ vi.mock('@/context/permission-state', async () => { return createPermissionStateModuleMock(() => mockConsoleState.current) }) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.byPipelineId.exports.get': () => ({ - mutateAsync: mockExportPipeline, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.exports.get': (input) => mockExportPipeline(input), }), - }) + } }) vi.mock('@/service/datasets', () => ({ diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx index bfc4998dcf7d94..076ed7b639fd17 100644 --- a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -1,9 +1,7 @@ -import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog' import type { SnippetDetail } from '@/models/snippet' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import SnippetInfoDropdown from '../dropdown' const mockReplace = vi.fn() @@ -44,74 +42,6 @@ vi.mock('@/app/notifications', () => ({ }, })) -vi.mock('@/service/use-snippets', () => ({ - useUpdateSnippetMutation: () => ({ - mutate: mockUpdateMutate, - isPending: false, - }), - useExportSnippetMutation: () => ({ - mutateAsync: mockExportMutateAsync, - isPending: false, - }), - useDeleteSnippetMutation: () => ({ - mutate: mockDeleteMutate, - isPending: false, - }), -})) - -type MockCreateSnippetDialogProps = { - isOpen: boolean - title?: string - confirmText?: string - initialValue?: { - name?: string - description?: string - } - onClose: () => void - onConfirm: (payload: CreateSnippetDialogPayload) => void -} - -vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ - CreateSnippetDialog: ({ - isOpen, - title, - confirmText, - initialValue, - onClose, - onConfirm, - }: MockCreateSnippetDialogProps) => { - if (!isOpen) return null - - return ( -
-
{title}
-
{confirmText}
-
{initialValue?.name}
-
{initialValue?.description}
- - -
- ) - }, -})) - const mockSnippet: SnippetDetail = { id: 'snippet-1', name: 'Social Media Repurposer', @@ -122,6 +52,22 @@ const mockSnippet: SnippetDetail = { status: undefined, } +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.customizedSnippets.bySnippetId.patch': (...args: unknown[]) => + mockUpdateMutate(...args), + 'workspaces.current.customizedSnippets.bySnippetId.delete': (...args: unknown[]) => + mockDeleteMutate(...args), + 'workspaces.current.customizedSnippets.bySnippetId.export.get': (...args: unknown[]) => + mockExportMutateAsync(...args), + }), + } +}) + describe('SnippetInfoDropdown', () => { beforeEach(() => { vi.clearAllMocks() @@ -170,39 +116,28 @@ describe('SnippetInfoDropdown', () => { describe('Edit Snippet', () => { it('should open the edit dialog and submit snippet updates', async () => { const user = userEvent.setup() - mockUpdateMutate.mockImplementation( - (_variables: unknown, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }, - ) + mockUpdateMutate.mockResolvedValue({}) render() await user.click(screen.getByRole('button', { name: 'common.operation.more' })) await user.click(screen.getByText('snippet.menu.editInfo')) - expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument() - expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument() - expect(screen.getByText('common.operation.save')).toBeInTheDocument() - expect(screen.getByText(mockSnippet.name)).toBeInTheDocument() - if (!mockSnippet.description) - throw new Error('mockSnippet.description is required for this test') - expect(screen.getByText(mockSnippet.description)).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'submit-edit' })) - - expect(mockUpdateMutate).toHaveBeenCalledWith( - { - params: { snippetId: mockSnippet.id }, - body: { - name: 'Updated snippet', - description: 'Updated description', - }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), + const name = screen.getByPlaceholderText('workflow.snippet.namePlaceholder') + const description = screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder') + expect(name).toHaveValue(mockSnippet.name) + expect(description).toHaveValue(mockSnippet.description) + await user.clear(name) + await user.type(name, 'Updated snippet') + await user.clear(description) + await user.type(description, 'Updated description') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => + expect(mockUpdateMutate).toHaveBeenCalledWith({ + params: { snippet_id: mockSnippet.id }, + body: { name: 'Updated snippet', description: 'Updated description' }, }), ) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) }) @@ -219,7 +154,10 @@ describe('SnippetInfoDropdown', () => { await user.click(screen.getByText('snippet.menu.exportSnippet')) await waitFor(() => { - expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id }) + expect(mockExportMutateAsync).toHaveBeenCalledWith({ + params: { snippet_id: mockSnippet.id }, + query: { include_secret: 'false', workflow_id: undefined }, + }) }) expect(mockDownloadBlob).toHaveBeenCalledWith({ @@ -248,11 +186,7 @@ describe('SnippetInfoDropdown', () => { describe('Delete Snippet', () => { it('should confirm deletion and redirect to the snippets list', async () => { const user = userEvent.setup() - mockDeleteMutate.mockImplementation( - (_variables: unknown, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }, - ) + mockDeleteMutate.mockResolvedValue({}) render() @@ -264,15 +198,10 @@ describe('SnippetInfoDropdown', () => { await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' })) - expect(mockDeleteMutate).toHaveBeenCalledWith( - { - params: { snippetId: mockSnippet.id }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), + await waitFor(() => + expect(mockDeleteMutate).toHaveBeenCalledWith({ params: { snippet_id: mockSnippet.id } }), ) + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/snippets')) expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted') expect(mockReplace).toHaveBeenCalledWith('/snippets') }) diff --git a/web/app/components/app/access-config/__tests__/index.spec.tsx b/web/app/components/app/access-config/__tests__/index.spec.tsx index 957f347bffa707..462fc89ed60e72 100644 --- a/web/app/components/app/access-config/__tests__/index.spec.tsx +++ b/web/app/components/app/access-config/__tests__/index.spec.tsx @@ -82,8 +82,6 @@ const mockMutations = vi.hoisted(() => ({ updateAutomaticIncludeWorkspaceMembers: vi.fn(), updateUserAccessSettings: vi.fn(), removeMemberBindings: vi.fn(), - removeMemberBindingsAsync: vi.fn(), - isUpdatingAutomaticIncludeWorkspaceMembers: false, })) vi.mock('@/context/permission-state', async () => { @@ -91,7 +89,8 @@ vi.mock('@/context/permission-state', async () => { return createPermissionStateModuleMock(() => mockConsoleState) }) -vi.mock('@/service/access-control/use-app-access-config', () => ({ +vi.mock('@/service/access-control/use-app-access-config', async (importOriginal) => ({ + ...(await importOriginal()), useAppAccessRules: vi.fn(() => ({ data: { items: mockAppAccessRules.items }, isLoading: mockAppAccessRules.isLoading, @@ -106,17 +105,6 @@ vi.mock('@/service/access-control/use-app-access-config', () => ({ })), useAppResourceWhitelist: vi.fn(() => ({ data: mockAppResourceWhitelist.data })), useAppResourceWhitelistConfig: vi.fn(() => ({ data: mockAppResourceWhitelistConfig.data })), - useUpdateAppAutomaticIncludeWorkspaceMembers: vi.fn(() => ({ - mutate: mockMutations.updateAutomaticIncludeWorkspaceMembers, - isPending: mockMutations.isUpdatingAutomaticIncludeWorkspaceMembers, - })), - useUpdateAppUserAccessSettings: vi.fn(() => ({ - mutate: mockMutations.updateUserAccessSettings, - })), - useRemoveAppAccessPolicyMemberBindings: vi.fn(() => ({ - mutate: mockMutations.removeMemberBindings, - mutateAsync: mockMutations.removeMemberBindingsAsync, - })), })) vi.mock('@/app/components/access-rules-editor', () => ({ @@ -126,6 +114,22 @@ vi.mock('@/app/components/access-rules-editor', () => ({ }, })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.rbac.apps.byAppId.whitelist.put': + mockMutations.updateAutomaticIncludeWorkspaceMembers, + 'workspaces.current.rbac.apps.byAppId.users.byTargetAccountId.accessPolicies.put': + mockMutations.updateUserAccessSettings, + 'workspaces.current.rbac.apps.byAppId.accessPolicies.byPolicyId.memberBindings.delete': + mockMutations.removeMemberBindings, + }), + } +}) + describe('AppAccessConfigPage', () => { beforeEach(() => { vi.clearAllMocks() @@ -149,8 +153,9 @@ describe('AppAccessConfigPage', () => { mockAppResourceWhitelistConfig.data = { automatic_include_workspace_members: false, } - mockMutations.isUpdatingAutomaticIncludeWorkspaceMembers = false - mockMutations.removeMemberBindingsAsync.mockResolvedValue(undefined) + mockMutations.removeMemberBindings.mockResolvedValue({}) + mockMutations.updateUserAccessSettings.mockResolvedValue({}) + mockMutations.updateAutomaticIncludeWorkspaceMembers.mockResolvedValue({}) mockAccessRulesEditor.props = null appDetail = createAppDetailFixture({ id: 'app-1', @@ -221,19 +226,21 @@ describe('AppAccessConfigPage', () => { expect(mockAccessRulesEditor.props?.pageSize).toBe(50) }) - it('should return to the first page when automatic member inclusion is disabled', () => { + it('should return to the first page when automatic member inclusion is disabled', async () => { mockAppResourceWhitelistConfig.data = { automatic_include_workspace_members: true, } render() act(() => mockAccessRulesEditor.props?.onPageChange?.(2)) - act(() => mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(false)) + await act(async () => { + mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(false) + }) expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 1, 10) }) - it('should jump to the new last page after adding a member', () => { + it('should jump to the new last page after adding a member', async () => { mockAppUserAccessSettings.pagination = { current_page: 2, per_page: 10, @@ -246,20 +253,21 @@ describe('AppAccessConfigPage', () => { } render() - act(() => mockAccessRulesEditor.props?.onAddAccessSubject?.('account-21', ['default'])) + await act(async () => { + mockAccessRulesEditor.props?.onAddAccessSubject?.('account-21', ['default']) + }) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( - { accountId: 'account-21', accessPolicyIds: ['default'] }, - expect.objectContaining({ onSuccess: expect.any(Function), onSettled: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ + params: { app_id: 'app-1', target_account_id: 'account-21' }, + body: { access_policy_ids: ['default'] }, + }), ) - const callbacks = mockMutations.updateUserAccessSettings.mock.calls.at(-1)?.[1] as { - onSuccess: () => void - } - act(() => callbacks.onSuccess()) + await act(async () => {}) expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10) }) - it('should load the previous page after removing the only member on the last page', () => { + it('should load the previous page after removing the only member on the last page', async () => { mockAppUserAccessSettings.data = [createUserAccessSetting('account-11')] mockAppUserAccessSettings.pagination = { current_page: 2, @@ -273,9 +281,6 @@ describe('AppAccessConfigPage', () => { act(() => mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-11', 'default'), ) - const callbacks = mockMutations.removeMemberBindings.mock.calls.at(-1)?.[1] as { - onSuccess: () => void - } mockAppUserAccessSettings.data = [] mockAppUserAccessSettings.pagination = { current_page: 2, @@ -284,7 +289,7 @@ describe('AppAccessConfigPage', () => { total_pages: 1, } mockAppUserAccessSettings.isPlaceholderData = true - act(() => callbacks.onSuccess()) + await act(async () => {}) expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 1, 10) expect(mockAccessRulesEditor.props?.isLoadingUserAccessSettings).toBe(true) @@ -294,21 +299,27 @@ describe('AppAccessConfigPage', () => { render() mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(true) - expect(mockMutations.updateAutomaticIncludeWorkspaceMembers).toHaveBeenCalledWith( - true, - expect.objectContaining({ onError: expect.any(Function), onSuccess: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.updateAutomaticIncludeWorkspaceMembers).toHaveBeenCalledWith({ + params: { app_id: 'app-1' }, + body: { automatic_include_workspace_members: true }, + }), ) mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-1', ['policy-1']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( - { accountId: 'account-1', accessPolicyIds: ['policy-1'] }, - expect.objectContaining({ onSettled: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ + params: { app_id: 'app-1', target_account_id: 'account-1' }, + body: { access_policy_ids: ['policy-1'] }, + }), ) mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-3', 'policy-3') - expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith( - [{ accessPolicyId: 'policy-3', accountIds: ['account-3'] }], - expect.objectContaining({ onSettled: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ + params: { app_id: 'app-1', policy_id: 'policy-3' }, + body: { account_ids: ['account-3'] }, + }), ) const removals = [ @@ -318,8 +329,14 @@ describe('AppAccessConfigPage', () => { await act(async () => { await mockAccessRulesEditor.props?.onBatchRemoveAccessPolicyMemberBindings?.(removals) }) - expect(mockMutations.removeMemberBindingsAsync).toHaveBeenCalledOnce() - expect(mockMutations.removeMemberBindingsAsync).toHaveBeenCalledWith(removals) + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ + params: { app_id: 'app-1', policy_id: 'policy-1' }, + body: { account_ids: ['account-1', 'account-2'] }, + }) + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ + params: { app_id: 'app-1', policy_id: 'default' }, + body: { account_ids: ['account-4'] }, + }) }) it('should block member changes but allow permission updates during automatic inclusion', async () => { @@ -338,12 +355,13 @@ describe('AppAccessConfigPage', () => { expect(mockMutations.updateUserAccessSettings).not.toHaveBeenCalled() expect(mockMutations.removeMemberBindings).not.toHaveBeenCalled() - expect(mockMutations.removeMemberBindingsAsync).not.toHaveBeenCalled() mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-2', ['policy-2']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( - { accountId: 'account-2', accessPolicyIds: ['policy-2'] }, - expect.objectContaining({ onSettled: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ + params: { app_id: 'app-1', target_account_id: 'account-2' }, + body: { access_policy_ids: ['policy-2'] }, + }), ) }) diff --git a/web/app/components/app/annotation/__tests__/filter.spec.tsx b/web/app/components/app/annotation/__tests__/filter.spec.tsx index 9733c5ddd4f662..a530587815eba3 100644 --- a/web/app/components/app/annotation/__tests__/filter.spec.tsx +++ b/web/app/components/app/annotation/__tests__/filter.spec.tsx @@ -1,5 +1,3 @@ -import type { AnnotationCountResponse } from '@dify/contracts/api/console/apps/types.gen' -import type { UseQueryResult } from '@tanstack/react-query' import type { QueryParam } from '../filter' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' @@ -8,27 +6,6 @@ import Filter from '../filter' const mockUseQuery = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => ({ - ...(await importOriginal()), - useQuery: (options: unknown) => mockUseQuery(options), -})) - -vi.mock('@/service/console', () => ({ - consoleQuery: { - apps: { - byAppId: { - annotations: { - count: { - get: { - queryOptions: (options: unknown) => options, - }, - }, - }, - }, - }, - }, -})) - // ============================================================================ // Test Utilities // ============================================================================ @@ -51,18 +28,6 @@ const renderWithQueryClient = (ui: React.ReactElement) => { // Mock Return Value Factory // ============================================================================ -type MockQueryResult = Pick, 'data' | 'isLoading' | 'error' | 'refetch'> - -const createMockQueryResult = ( - overrides: Partial> = {}, -): MockQueryResult => ({ - data: undefined, - isLoading: false, - error: null, - refetch: vi.fn(), - ...overrides, -}) - // ============================================================================ // Tests // ============================================================================ @@ -80,11 +45,9 @@ describe('Filter', () => { // Rendering Tests (REQUIRED) // -------------------------------------------------------------------------- describe('Rendering', () => { - it('should render nothing when data is loading', () => { + it('should render nothing when data is loading', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ isLoading: true }), - ) + mockUseQuery.mockReturnValue(new Promise(() => {})) // Act const { container } = renderWithQueryClient( @@ -97,11 +60,9 @@ describe('Filter', () => { expect(container.firstChild).toBeNull() }) - it('should render nothing when data is undefined', () => { + it('should render nothing when data is undefined', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ data: undefined, isLoading: false }), - ) + mockUseQuery.mockReturnValue(new Promise(() => {})) // Act const { container } = renderWithQueryClient( @@ -114,14 +75,9 @@ describe('Filter', () => { expect(container.firstChild).toBeNull() }) - it('should render filter and children when data is available', () => { + it('should render filter and children when data is available', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 20 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 20 }) // Act renderWithQueryClient( @@ -131,7 +87,9 @@ describe('Filter', () => { ) // Assert - expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toBeInTheDocument() + expect( + await screen.findByRole('searchbox', { name: 'common.operation.search' }), + ).toBeInTheDocument() expect(screen.getByText(childContent)).toBeInTheDocument() }) }) @@ -140,14 +98,9 @@ describe('Filter', () => { // Props Tests (REQUIRED) // -------------------------------------------------------------------------- describe('Props', () => { - it('should query the annotations count for appId', () => { + it('should query the annotations count for appId', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 10 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 10 }) // Act renderWithQueryClient( @@ -157,17 +110,12 @@ describe('Filter', () => { ) // Assert - expect(mockUseQuery).toHaveBeenCalledWith({ input: { params: { app_id: appId } } }) + expect(mockUseQuery).toHaveBeenCalledWith({ params: { app_id: appId } }) }) - it('should display keyword value in input', () => { + it('should display keyword value in input', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 10 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 10 }) const queryParams: QueryParam = { keyword: 'test-keyword' } // Act @@ -178,7 +126,7 @@ describe('Filter', () => { ) // Assert - expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveValue( + expect(await screen.findByRole('searchbox', { name: 'common.operation.search' })).toHaveValue( 'test-keyword', ) }) @@ -188,14 +136,9 @@ describe('Filter', () => { // User Interactions // -------------------------------------------------------------------------- describe('User Interactions', () => { - it('should call setQueryParams when typing in search input', () => { + it('should call setQueryParams when typing in search input', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 20 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 20 }) const queryParams: QueryParam = { keyword: '' } const setQueryParams = vi.fn() @@ -206,21 +149,16 @@ describe('Filter', () => { ) // Act - const input = screen.getByRole('searchbox', { name: 'common.operation.search' }) + const input = await screen.findByRole('searchbox', { name: 'common.operation.search' }) fireEvent.change(input, { target: { value: 'updated' } }) // Assert expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' }) }) - it('should call setQueryParams with empty keyword when clearing input', () => { + it('should call setQueryParams with empty keyword when clearing input', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 20 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 20 }) const queryParams: QueryParam = { keyword: 'prefill' } const setQueryParams = vi.fn() @@ -231,7 +169,7 @@ describe('Filter', () => { ) // Act - fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) + fireEvent.click(await screen.findByRole('button', { name: 'common.operation.clear' })) // Assert expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' }) @@ -242,14 +180,9 @@ describe('Filter', () => { // Edge Cases (REQUIRED) // -------------------------------------------------------------------------- describe('Edge Cases', () => { - it('should handle empty keyword in queryParams', () => { + it('should handle empty keyword in queryParams', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 5 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 5 }) // Act renderWithQueryClient( @@ -259,17 +192,14 @@ describe('Filter', () => { ) // Assert - expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveValue('') + expect(await screen.findByRole('searchbox', { name: 'common.operation.search' })).toHaveValue( + '', + ) }) - it('should handle undefined keyword in queryParams', () => { + it('should handle undefined keyword in queryParams', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 5 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 5 }) // Act renderWithQueryClient( @@ -279,17 +209,14 @@ describe('Filter', () => { ) // Assert - expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toBeInTheDocument() + expect( + await screen.findByRole('searchbox', { name: 'common.operation.search' }), + ).toBeInTheDocument() }) - it('should handle zero count', () => { + it('should handle zero count', async () => { // Arrange - mockUseQuery.mockReturnValue( - createMockQueryResult({ - data: { count: 0 }, - isLoading: false, - }), - ) + mockUseQuery.mockResolvedValue({ count: 0 }) // Act renderWithQueryClient( @@ -299,7 +226,20 @@ describe('Filter', () => { ) // Assert - should still render when count is 0 - expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toBeInTheDocument() + expect( + await screen.findByRole('searchbox', { name: 'common.operation.search' }), + ).toBeInTheDocument() }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.annotations.count.get': (input) => mockUseQuery(input), + }), + } +}) diff --git a/web/app/components/app/annotation/__tests__/index.spec.tsx b/web/app/components/app/annotation/__tests__/index.spec.tsx index 9bb6ab9b3183b4..f8703436fd92e2 100644 --- a/web/app/components/app/annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/__tests__/index.spec.tsx @@ -14,10 +14,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, })) -vi.mock('ahooks', () => ({ - useDebounce: (value: T) => value, -})) - const mocks = vi.hoisted(() => ({ addAnnotation: vi.fn(), delAnnotation: vi.fn(), diff --git a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx index c983f3fa2a811f..f5ab37ab353925 100644 --- a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx @@ -67,14 +67,18 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ }), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.tools.builtin.get': () => ({ data: [] }), - 'workspaces.current.tools.api.get': () => ({ data: [] }), - 'workspaces.current.tools.mcp.get': () => ({ data: [] }), - 'workspaces.current.tools.workflow.get': () => ({ data: [] }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.tools.builtin.get': async () => [], + 'workspaces.current.tools.api.get': async () => [], + 'workspaces.current.tools.mcp.get': async () => [], + 'workspaces.current.tools.workflow.get': async () => [], + }), + } }) function publishedWorkflowVersion({ diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index ca9c73d8771e0c..fc79b1e2cbe262 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -137,14 +137,15 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () }, })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.toolProvider.workflow.get.get': () => ({ - data: undefined, - isLoading: false, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.toolProvider.workflow.get.get': async () => undefined, }), - }) + } }) vi.mock('@/context/workspace-state', async () => { diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx index 3832523e919028..2629b7f52b8bff 100644 --- a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -12,10 +12,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useLanguage: () => 'en_US', })) -vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ - default: ({ modelName }: { modelName: string }) => {modelName}, -})) - describe('PublishWithMultipleModel', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx index 00b7ae61f5257c..9d2970f69db9ee 100644 --- a/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx @@ -1,23 +1,19 @@ import type { Mock } from 'vite-plus/test' import type { FeatureStoreState } from '@/app/components/base/features/store' import type { FileUpload } from '@/app/components/base/features/types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { renderWithDebugConfiguration } from '@/test/debug-configuration' import { Resolution, TransferMethod } from '@/types/app' import ConfigVision from '../index' import ParamConfig from '../param-config' import ParamConfigContent from '../param-config-content' -const mockUseContext = vi.fn() -vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useContext: (context: unknown) => mockUseContext(context), - } -}) +let configuration: Parameters[1] = {} +const render = (ui: Parameters[0]) => + renderWithDebugConfiguration(ui, configuration) const mockUseFeatures = vi.fn() const mockUseFeaturesStore = vi.fn() @@ -76,20 +72,20 @@ const getLatestFileConfig = () => { beforeEach(() => { vi.clearAllMocks() - mockUseContext.mockReturnValue({ + configuration = { isShowVisionConfig: true, isAllowVideoUpload: false, - }) + } setupFeatureStore() }) // ConfigVision handles toggling file upload types + visibility rules. describe('ConfigVision', () => { it('should not render when vision configuration is hidden', () => { - mockUseContext.mockReturnValue({ + configuration = { isShowVisionConfig: false, isAllowVideoUpload: false, - }) + } render() @@ -110,10 +106,10 @@ describe('ConfigVision', () => { it('should enable both image and video uploads when toggled on with video support', async () => { const user = userEvent.setup() - mockUseContext.mockReturnValue({ + configuration = { isShowVisionConfig: true, isAllowVideoUpload: true, - }) + } setupFeatureStore({ allowed_file_types: [], }) @@ -132,10 +128,10 @@ describe('ConfigVision', () => { it('should disable image and video uploads when toggled off and no other types remain', async () => { const user = userEvent.setup() - mockUseContext.mockReturnValue({ + configuration = { isShowVisionConfig: true, isAllowVideoUpload: true, - }) + } setupFeatureStore({ allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.video], enabled: true, @@ -155,10 +151,10 @@ describe('ConfigVision', () => { it('should keep file uploads enabled when other file types remain after disabling vision', async () => { const user = userEvent.setup() - mockUseContext.mockReturnValue({ + configuration = { isShowVisionConfig: true, isAllowVideoUpload: false, - }) + } setupFeatureStore({ allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.document], enabled: true, diff --git a/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx b/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx index 93fb746875d501..9ef58b586f111f 100644 --- a/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx +++ b/web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx @@ -1,18 +1,14 @@ import type { FeatureStoreState } from '@/app/components/base/features/store' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { renderWithDebugConfiguration } from '@/test/debug-configuration' import ConfigAudio from '../config-audio' -const mockUseContext = vi.fn() -vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useContext: (context: unknown) => mockUseContext(context), - } -}) +let configuration: Parameters[1] = {} +const render = (ui: Parameters[0]) => + renderWithDebugConfiguration(ui, configuration) const mockUseFeatures = vi.fn() const mockUseFeaturesStore = vi.fn() @@ -53,9 +49,9 @@ const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => { const renderConfigAudio = (options: SetupOptions = {}) => { const { isVisible = true, allowedTypes = [] } = options setupFeatureStore(allowedTypes) - mockUseContext.mockReturnValue({ + configuration = { isShowAudioConfig: isVisible, - }) + } const user = userEvent.setup() render() return { diff --git a/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx b/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx index caa084bfa516b5..4f3ce621b792f8 100644 --- a/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx +++ b/web/app/components/app/configuration/config/__tests__/config-document.spec.tsx @@ -1,18 +1,14 @@ import type { FeatureStoreState } from '@/app/components/base/features/store' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { renderWithDebugConfiguration } from '@/test/debug-configuration' import ConfigDocument from '../config-document' -const mockUseContext = vi.fn() -vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useContext: (context: unknown) => mockUseContext(context), - } -}) +let configuration: Parameters[1] = {} +const render = (ui: Parameters[0]) => + renderWithDebugConfiguration(ui, configuration) const mockUseFeatures = vi.fn() const mockUseFeaturesStore = vi.fn() @@ -53,9 +49,9 @@ const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => { const renderConfigDocument = (options: SetupOptions = {}) => { const { isVisible = true, allowedTypes = [] } = options setupFeatureStore(allowedTypes) - mockUseContext.mockReturnValue({ + configuration = { isShowDocumentConfig: isVisible, - }) + } const user = userEvent.setup() render() return { diff --git a/web/app/components/app/configuration/config/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/__tests__/index.spec.tsx index df48caa4110ac7..5edd4dece6041c 100644 --- a/web/app/components/app/configuration/config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/__tests__/index.spec.tsx @@ -1,20 +1,16 @@ -import type { AppMode } from '@dify/contracts/api/console/apps/types.gen' import type { ComponentProps } from 'react' +import type { ConfigurationAppMode } from '@/app/components/app/configuration/hooks/configuration-lifecycle/types' import type { ModelConfig, PromptVariable } from '@/models/debug' import type { ToolItem } from '@/types/app' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import * as React from 'react' -import * as useContextSelector from 'use-context-selector' +import { renderWithDebugConfiguration } from '@/test/debug-configuration' import { AgentStrategy, ModelModeType } from '@/types/app' import Config from '../index' -vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useContext: vi.fn(), - } -}) +let configuration: Parameters[1] = {} +const render = (ui: Parameters[0]) => + renderWithDebugConfiguration(ui, configuration) const mockFormattingDispatcher = vi.fn() vi.mock('../../debug/hooks', () => ({ @@ -82,7 +78,7 @@ vi.mock('../../config-prompt/conversation-history/history-panel', () => ({ })) type MockContext = { - mode: AppMode + mode: ConfigurationAppMode isAdvancedMode: boolean modelModeType: ModelModeType isAgent: boolean @@ -152,11 +148,9 @@ const createContextValue = (overrides: Partial = {}): MockContext = ...overrides, }) -const mockUseContext = vi.mocked(useContextSelector.useContext) - const renderConfig = (contextOverrides: Partial = {}) => { const contextValue = createContextValue(contextOverrides) - mockUseContext.mockReturnValue(contextValue) + configuration = contextValue return { contextValue, ...render(), diff --git a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx index d85fd20d9249fd..357b23fddaac64 100644 --- a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx @@ -1,15 +1,14 @@ import type { DatasetDetailWithPartialMembersResponse } from '@dify/contracts/api/console/datasets/types.gen' -import type { ComponentProps, PropsWithChildren } from 'react' +import type { ComponentProps } from 'react' import type { DatasetConfigs } from '@/models/debug' import { fireEvent, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useContext } from 'use-context-selector' import { ComparisonOperator, LogicalOperator, MetadataFilteringModeEnum, } from '@/app/components/workflow/nodes/knowledge-retrieval/types' -import { getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' +import DebugConfigurationContext from '@/context/debug-configuration' import { DataSourceType } from '@/models/datasets' import { PermissionLevel } from '@/models/permission' import { createConsoleQueryWrapper } from '@/test/console/query-data' @@ -21,7 +20,7 @@ import { createModelConfigFixture, } from '@/test/fixtures/debug-configuration' import { ModelModeType, RETRIEVE_TYPE } from '@/types/app' -import { DatasetACLPermission, getDatasetACLCapabilities } from '@/utils/permission' +import { DatasetACLPermission } from '@/utils/permission' import DatasetConfig from '../index' const render = (ui: Parameters[0]) => @@ -30,23 +29,6 @@ const render = (ui: Parameters[0]) => }) // Mock external dependencies -vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/utils', () => ({ - getMultipleRetrievalConfig: vi.fn(() => ({ - top_k: 4, - score_threshold: 0.7, - reranking_enable: false, - reranking_model: undefined, - reranking_mode: 'reranking_model', - weights: { weight1: 1.0 }, - })), - getSelectedDatasetsMode: vi.fn(() => ({ - allInternal: true, - allExternal: false, - mixtureInternalAndExternal: false, - mixtureHighQualityAndEconomic: false, - inconsistentEmbeddingModel: false, - })), -})) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ @@ -63,27 +45,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/utils/permission', () => ({ - DatasetACLPermission: { - Readonly: 'dataset.acl.readonly', - Edit: 'dataset.acl.edit', - Use: 'dataset.acl.use', - }, - getDatasetACLCapabilities: vi.fn(() => ({ - canReadonly: false, - canEdit: true, - canImportExportDSL: false, - canPipelineTest: false, - canDocumentDownload: false, - canRetrievalRecall: false, - canUse: true, - canDeleteFile: false, - canPipelineRelease: false, - canDelete: false, - canAccessConfig: false, - })), -})) - vi.mock('../../debug/hooks', () => ({ useFormattingChangedDispatcher: vi.fn(() => vi.fn()), })) @@ -248,21 +209,13 @@ const mockConfigContext = createDebugConfigurationFixture({ setRerankSettingModalOpen: vi.fn(), }) -vi.mock('@/context/debug-configuration', () => ({ - default: ({ children }: PropsWithChildren) => ( -
{children}
- ), -})) - -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(() => mockConfigContext), -})) - const createMockDataset = ( overrides: Partial = {}, ): DatasetDetailWithPartialMembersResponse => { const defaultDataset: DatasetDetailWithPartialMembersResponse = { - ...createDatasetFixture(), + ...createDatasetFixture({ + permission_keys: [DatasetACLPermission.Edit, DatasetACLPermission.Use], + }), id: 'dataset-1', name: 'Test Dataset', icon_info: { @@ -330,27 +283,18 @@ const createMockDataset = ( const renderDatasetConfig = (contextOverrides: Partial = {}) => { const mergedContext = { ...mockConfigContext, ...contextOverrides } - vi.mocked(useContext).mockReturnValue(mergedContext) - return render() + return render( + + + , + ) } describe('DatasetConfig', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(getDatasetACLCapabilities).mockReturnValue({ - canReadonly: false, - canEdit: true, - canImportExportDSL: false, - canPipelineTest: false, - canDocumentDownload: false, - canRetrievalRecall: false, - canUse: true, - canDeleteFile: false, - canPipelineRelease: false, - canDelete: false, - canAccessConfig: false, - }) + mockConfigContext.dataSets = [] mockConfigContext.setDataSets = vi.fn() mockConfigContext.setModelConfig = vi.fn() @@ -428,22 +372,12 @@ describe('DatasetConfig', () => { it('should trigger rerank setting modal when removing dataset requires rerank configuration', async () => { const user = userEvent.setup() - // Mock scenario that triggers rerank modal - // @ts-expect-error - same as above - vi.mocked(getSelectedDatasetsMode).mockReturnValue({ - allInternal: false, - allExternal: true, - mixtureInternalAndExternal: false, - mixtureHighQualityAndEconomic: false, - inconsistentEmbeddingModel: false, - }) - const dataset = createMockDataset() renderDatasetConfig({ - dataSets: [dataset], + dataSets: [dataset, createMockDataset({ id: 'external-dataset', provider: 'external' })], }) - const removeButton = screen.getByText('Remove') + const removeButton = screen.getAllByText('Remove')[0]! await user.click(removeButton) expect(mockConfigContext.setRerankSettingModalOpen).toHaveBeenCalledWith(true) @@ -489,19 +423,6 @@ describe('DatasetConfig', () => { permission: PermissionLevel.allTeamMembers, permission_keys: [DatasetACLPermission.Use], }) - vi.mocked(getDatasetACLCapabilities).mockReturnValue({ - canReadonly: false, - canEdit: false, - canImportExportDSL: false, - canPipelineTest: false, - canDocumentDownload: false, - canRetrievalRecall: false, - canUse: true, - canDeleteFile: false, - canPipelineRelease: false, - canDelete: false, - canAccessConfig: false, - }) renderDatasetConfig({ dataSets: [dataset], @@ -509,13 +430,6 @@ describe('DatasetConfig', () => { expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument() expect(screen.queryByText('Edit')).not.toBeInTheDocument() - expect(getDatasetACLCapabilities).toHaveBeenCalledWith( - dataset.permission_keys, - expect.objectContaining({ - currentUserId: 'user-123', - resourceMaintainer: dataset.maintainer, - }), - ) }) }) @@ -852,21 +766,6 @@ describe('DatasetConfig', () => { expect(screen.getByTestId('metadata-list-count'))!.toHaveTextContent('0') }) - it('should handle missing userProfile', () => { - vi.mocked(useContext).mockReturnValue({ - ...mockConfigContext, - userProfile: null, - }) - - const dataset = createMockDataset() - - renderDatasetConfig({ - dataSets: [dataset], - }) - - expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument() - }) - it('should handle missing datasetConfigsRef gracefully', () => { const dataset = createMockDataset() diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx index d127c29ebe7fb1..d9c757fe5ffbed 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx @@ -4,25 +4,26 @@ import * as React from 'react' import { describe, expect, it, vi } from 'vite-plus/test' import { IndexingType } from '@/app/components/datasets/create/step-two' import { PermissionLevel } from '@/models/permission' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { RETRIEVE_METHOD } from '@/types/app' import SelectDataSet from '../index' -const mockUseInfiniteScroll = vi.fn() -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal() +const getDatasets = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') return { - ...(typeof actual === 'object' && actual !== null ? actual : {}), - useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args), + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { 'datasets.get': getDatasets }), } }) - -const { mockDatasetsInfiniteQuery } = vi.hoisted(() => ({ mockDatasetsInfiniteQuery: vi.fn() })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.get': mockDatasetsInfiniteQuery, - }) +const page = (data: DatasetDetailWithPartialMembersResponse[], page = 1, has_more = false) => ({ + data, + page, + limit: 20, + total: data.length, + has_more, }) let mockWorkspacePermissionKeys = ['dataset.create_and_management'] @@ -82,9 +83,14 @@ const makeDataset = ( describe('SelectDataSet', () => { beforeEach(() => { vi.clearAllMocks() + getDatasets.mockReset().mockResolvedValue(page([])) mockWorkspacePermissionKeys = ['dataset.create_and_management'] }) + afterEach(() => { + vi.restoreAllMocks() + }) + it('renders dataset entries, allows selection, and fires onSelect', async () => { const datasetOne = makeDataset({ id: 'set-1', @@ -98,20 +104,14 @@ describe('SelectDataSet', () => { embedding_available: false, provider: 'external', }) - mockDatasetsInfiniteQuery.mockReturnValue({ - data: { pages: [{ data: [datasetOne, datasetTwo] }] }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - hasNextPage: false, - }) + getDatasets.mockResolvedValue(page([datasetOne, datasetTwo])) const onSelect = vi.fn() await act(async () => { render() }) - expect(screen.getByText('Dataset One')).toBeInTheDocument() + expect(await screen.findByText('Dataset One')).toBeInTheDocument() expect(screen.getByText('Hidden Dataset')).toBeInTheDocument() await act(async () => { @@ -127,19 +127,13 @@ describe('SelectDataSet', () => { }) it('shows empty state when no datasets are available and disables add', async () => { - mockDatasetsInfiniteQuery.mockReturnValue({ - data: { pages: [{ data: [] }] }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - hasNextPage: false, - }) + getDatasets.mockResolvedValue(page([])) await act(async () => { render() }) - expect(screen.getByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() + expect(await screen.findByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute( 'href', '/datasets/create', @@ -149,19 +143,13 @@ describe('SelectDataSet', () => { it('should hide the create dataset link when dataset.create_and_management is unavailable', async () => { mockWorkspacePermissionKeys = [] - mockDatasetsInfiniteQuery.mockReturnValue({ - data: { pages: [{ data: [] }] }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - hasNextPage: false, - }) + getDatasets.mockResolvedValue(page([])) await act(async () => { render() }) - expect(screen.getByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() + expect(await screen.findByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() expect( screen.queryByRole('link', { name: 'appDebug.feature.dataSet.toCreate' }), ).not.toBeInTheDocument() @@ -173,19 +161,14 @@ describe('SelectDataSet', () => { id: 'set-1', name: 'Dataset One', }) - mockDatasetsInfiniteQuery.mockReturnValue({ - data: { pages: [{ data: [datasetOne] }] }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - hasNextPage: false, - }) + getDatasets.mockResolvedValue(page([datasetOne])) const onSelect = vi.fn() await act(async () => { render() }) + await screen.findByText('Dataset One') expect(screen.getByText('1 appDebug.feature.dataSet.selected')).toBeInTheDocument() await act(async () => { @@ -206,18 +189,13 @@ describe('SelectDataSet', () => { embedding_available: false, }) const onSelect = vi.fn() - mockDatasetsInfiniteQuery.mockReturnValue({ - data: { pages: [{ data: [datasetOne, datasetTwo] }] }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - hasNextPage: false, - }) + getDatasets.mockResolvedValue(page([datasetOne, datasetTwo])) await act(async () => { render() }) + await screen.findByText('Dataset One') await act(async () => { fireEvent.click(screen.getByText('Dataset One').parentElement?.parentElement as HTMLElement) }) @@ -236,51 +214,36 @@ describe('SelectDataSet', () => { expect(onSelect).toHaveBeenCalledWith([]) }) - it('should fetch the next page when infinite scroll can continue', async () => { - const fetchNextPage = vi.fn() - mockDatasetsInfiniteQuery.mockReturnValue({ - data: { pages: [{ data: [makeDataset({ id: 'set-1' })] }] }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage, - hasNextPage: true, - }) - - await act(async () => { - render() - }) - - const loadMore = mockUseInfiniteScroll.mock.calls.at(-1)?.[0] as () => Promise<{ - list: never[] - }> - await act(async () => { - await loadMore() - }) - - expect(fetchNextPage).toHaveBeenCalledTimes(1) + it('loads another page, retains the first page and does not duplicate a pending page request', async () => { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(1000) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(100) + const nextPage = createDeferred>() + getDatasets + .mockResolvedValueOnce(page([makeDataset({ id: 'set-1', name: 'First dataset' })], 1, true)) + .mockReturnValueOnce(nextPage.promise) + render() + expect(await screen.findByText('First dataset')).toBeInTheDocument() + await waitFor(() => expect(getDatasets).toHaveBeenCalledTimes(2)) + expect(getDatasets).toHaveBeenLastCalledWith({ query: { page: 2 } }) + expect(screen.getByText('First dataset')).toBeInTheDocument() + await act(async () => + nextPage.resolve(page([makeDataset({ id: 'set-2', name: 'Second dataset' })], 2)), + ) + expect(await screen.findByText('Second dataset')).toBeInTheDocument() + expect(screen.getByText('First dataset')).toBeInTheDocument() + expect(getDatasets).toHaveBeenCalledTimes(2) }) - it('should skip infinite scroll fetches when a next page is unavailable or already loading', async () => { - const fetchNextPage = vi.fn() - mockDatasetsInfiniteQuery.mockReturnValue({ - data: { pages: [{ data: [makeDataset({ id: 'set-1' })] }] }, - isLoading: false, - isFetchingNextPage: true, - fetchNextPage, - hasNextPage: false, - }) - - await act(async () => { - render() - }) - - const loadMore = mockUseInfiniteScroll.mock.calls.at(-1)?.[0] as () => Promise<{ - list: never[] - }> - await act(async () => { - await loadMore() - }) + it('does not request another page after the server reports the final page', async () => { + getDatasets.mockResolvedValue(page([makeDataset({ id: 'set-1', name: 'Final dataset' })])) + render() + await screen.findByText('Final dataset') + expect(getDatasets).toHaveBeenCalledOnce() + }) - expect(fetchNextPage).not.toHaveBeenCalled() + it('does not request datasets while the selector is closed', async () => { + render() + await act(async () => {}) + expect(getDatasets).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index 82e30217da81dd..5fdcc2c956daec 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -14,9 +14,10 @@ import { PermissionLevel } from '@/models/permission' import { consoleQuery } from '@/service/console' import { updateDatasetSetting } from '@/service/datasets' import { seedAccountProfileQuery } from '@/test/console/account-profile' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { renderWithConsoleQuery as renderWithoutUrlState } from '@/test/console/query-data' import { createSystemFeaturesFixture } from '@/test/console/system-features' import { createDatasetFixture } from '@/test/fixtures/dataset' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { RETRIEVE_METHOD } from '@/types/app' import { DatasetACLPermission } from '@/utils/permission' import SettingsModal from '../index' @@ -53,7 +54,6 @@ vi.mock('@/app/components/app/configuration/toast', () => ({ })) const mockOnCancel = vi.fn() const mockOnSave = vi.fn() -const mockSetSettingsDestination = vi.fn() const mockModelListQuery = vi.fn() const mockModelListQueryAndDefaultModel = vi.fn() @@ -79,16 +79,6 @@ vi.mock('@/service/datasets', () => ({ updateDatasetSetting: vi.fn(), })) -vi.mock('@/service/use-common', async () => ({ - ...(await vi.importActual('@/service/use-common')), - mockMembersQuery: vi.fn(), -})) - -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs${path}`, })) @@ -108,10 +98,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec ), })) -vi.mock('@/app/components/datasets/settings/utils', () => ({ - checkShowMultiModalTip: (...args: unknown[]) => mockCheckShowMultiModalTip(...args), -})) - const mockUpdateDatasetSetting = updateDatasetSetting as MockedFunction const mockUseMembers = mockMembersQuery as MockedFunction @@ -189,6 +175,9 @@ const createDataset = ( } } +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + const renderWithProviders = (dataset: DatasetDetailWithPartialMembersResponse) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, @@ -385,7 +374,9 @@ describe('SettingsModal', () => { ) // Assert - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'), + ) }) }) diff --git a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx index bc5522517e80bb..3696a627728f0a 100644 --- a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx @@ -1,15 +1,15 @@ import type { Inputs, ModelConfig } from '@/models/debug' import type { PromptVariable } from '@/types/app' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithDebugConfiguration } from '@/test/debug-configuration' import ChatUserInput from '../chat-user-input' +let configuration: Parameters[1] = {} +const render = (ui: Parameters[0]) => + renderWithDebugConfiguration(ui, configuration) + const mockSetInputs = vi.fn() -const mockUseContext = vi.fn() -vi.mock('use-context-selector', () => ({ - useContext: () => mockUseContext(), - createContext: vi.fn(() => ({})), -})) vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({ default: ({ @@ -88,70 +88,60 @@ const createContextValue = ( describe('ChatUserInput', () => { beforeEach(() => { vi.clearAllMocks() - mockUseContext.mockReturnValue(createContextValue()) + configuration = createContextValue() }) describe('Rendering', () => { it('should return null when no prompt variables exist', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([]), + }) const { container } = render() expect(container.firstChild).toBeNull() }) it('should return null when prompt variables have empty keys', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: '', name: 'Test' }), - createPromptVariable({ key: ' ', name: 'Test2' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: '', name: 'Test' }), + createPromptVariable({ key: ' ', name: 'Test2' }), + ]), + }) const { container } = render() expect(container.firstChild).toBeNull() }) it('should return null when prompt variables have empty names', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'key1', name: '' }), - createPromptVariable({ key: 'key2', name: ' ' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'key1', name: '' }), + createPromptVariable({ key: 'key2', name: ' ' }), + ]), + }) const { container } = render() expect(container.firstChild).toBeNull() }) it('should render string input type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument() }) it('should render paragraph input type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'description', name: 'Description', type: 'paragraph' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'description', name: 'Description', type: 'paragraph' }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument() @@ -159,18 +149,16 @@ describe('ChatUserInput', () => { it('should render select input type', async () => { const user = userEvent.setup() - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ - key: 'choice', - name: 'Choice', - type: 'select', - options: ['A', 'B', 'C'], - }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['A', 'B', 'C'], + }), + ]), + }) render() const select = screen.getByRole('combobox', { name: 'Choice' }) @@ -181,13 +169,11 @@ describe('ChatUserInput', () => { }) it('should render number input type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), + ]), + }) render() const input = screen.getByRole('spinbutton', { name: 'Count' }) @@ -196,33 +182,29 @@ describe('ChatUserInput', () => { }) it('should render checkbox input type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }) render() expect(screen.getByTestId('bool-input-Enabled')).toBeInTheDocument() }) it('should render multiple input types', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - createPromptVariable({ - key: 'choice', - name: 'Choice', - type: 'select', - options: ['X', 'Y'], - }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['X', 'Y'], + }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument() @@ -231,39 +213,33 @@ describe('ChatUserInput', () => { }) it('should show optional label for non-required fields', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: false }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: false }), + ]), + }) render() expect(screen.getByText(/(?:^|\.)panel\.optional(?=$|:)/)).toBeInTheDocument() }) it('should not show optional label for required fields', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: true }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', required: true }), + ]), + }) render() expect(screen.queryByText(/(?:^|\.)panel\.optional(?=$|:)/)).not.toBeInTheDocument() }) it('should use key as label when name is not provided', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'myKey', name: '', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'myKey', name: '', type: 'string' }), + ]), + }) // This should actually return null because name is empty const { container } = render() @@ -273,39 +249,33 @@ describe('ChatUserInput', () => { describe('Input Values', () => { it('should display existing input values for string type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('John') }) it('should display existing input values for paragraph type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Long text here') }) it('should display existing input values for number type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), + ]), + }) render() // Number type input still uses string value internally @@ -313,13 +283,11 @@ describe('ChatUserInput', () => { }) it('should display checkbox as checked when value is truthy', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }) render() const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input') @@ -327,13 +295,11 @@ describe('ChatUserInput', () => { }) it('should display checkbox as unchecked when value is falsy', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }) render() const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input') @@ -341,26 +307,22 @@ describe('ChatUserInput', () => { }) it('should handle empty string values', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('') }) it('should handle undefined values', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('') @@ -369,13 +331,11 @@ describe('ChatUserInput', () => { describe('User Interactions', () => { it('should call setInputs when string input changes', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }) render() fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { @@ -386,13 +346,11 @@ describe('ChatUserInput', () => { }) it('should call setInputs when paragraph input changes', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + }) render() fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { @@ -404,18 +362,16 @@ describe('ChatUserInput', () => { it('should call setInputs when select input changes', async () => { const user = userEvent.setup() - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ - key: 'choice', - name: 'Choice', - type: 'select', - options: ['A', 'B', 'C'], - }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['A', 'B', 'C'], + }), + ]), + }) render() await user.click(screen.getByRole('combobox', { name: 'Choice' })) @@ -425,13 +381,11 @@ describe('ChatUserInput', () => { }) it('should call setInputs when number input changes', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'count', name: 'Count', type: 'number' }), + ]), + }) render() fireEvent.change(screen.getByRole('spinbutton', { name: 'Count' }), { @@ -442,13 +396,11 @@ describe('ChatUserInput', () => { }) it('should call setInputs when checkbox changes', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + }) render() const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input')! @@ -465,19 +417,17 @@ describe('ChatUserInput', () => { callback(createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), 0), ], } - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: { - ...createModelConfig(), - configs: { - prompt_template: '', - prompt_variables: { - filter: () => filteredPromptVariables, - } as unknown as PromptVariable[], - }, + configuration = createContextValue({ + modelConfig: { + ...createModelConfig(), + configs: { + prompt_template: '', + prompt_variables: { + filter: () => filteredPromptVariables, + } as unknown as PromptVariable[], }, - }), - ) + }, + }) render() @@ -491,95 +441,83 @@ describe('ChatUserInput', () => { describe('Debug Permission', () => { it('should keep string input editable when configuration is readonly but test/run is allowed', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - readonly: true, - canTestAndRun: true, - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: true, + canTestAndRun: true, + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).not.toHaveAttribute('readonly') }) it('should set string input as readonly when test/run is denied even if configuration is editable', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - readonly: false, - canTestAndRun: false, - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: false, + canTestAndRun: false, + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('readonly') }) it('should set string input as readonly when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - readonly: true, - canTestAndRun: false, - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: true, + canTestAndRun: false, + }) render() expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('readonly') }) it('should set paragraph input as readonly when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - readonly: true, - canTestAndRun: false, - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + readonly: true, + canTestAndRun: false, + }) render() expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly') }) it('should disable select when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ - key: 'choice', - name: 'Choice', - type: 'select', - options: ['A', 'B'], - }), - ]), - readonly: true, - canTestAndRun: false, - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'choice', + name: 'Choice', + type: 'select', + options: ['A', 'B'], + }), + ]), + readonly: true, + canTestAndRun: false, + }) render() expect(screen.getByRole('combobox', { name: 'Choice' })).toBeDisabled() }) it('should disable checkbox when configuration is readonly and test/run is denied', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), - ]), - readonly: true, - canTestAndRun: false, - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), + ]), + readonly: true, + canTestAndRun: false, + }) render() const checkbox = screen.getByTestId('bool-input-Enabled').querySelector('input') @@ -589,18 +527,16 @@ describe('ChatUserInput', () => { describe('Default Values', () => { it('should initialize inputs with default values when field is empty', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ - key: 'name', - name: 'Name', - type: 'string', - default: 'Default Name', - }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'name', + name: 'Name', + type: 'string', + default: 'Default Name', + }), + ]), + }) render() @@ -608,13 +544,11 @@ describe('ChatUserInput', () => { }) it('should not override existing values with defaults', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: 'Default' }), + ]), + }) render() @@ -623,19 +557,17 @@ describe('ChatUserInput', () => { }) it('should handle multiple default values', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ - key: 'name', - name: 'Name', - type: 'string', - default: 'Default Name', - }), - createPromptVariable({ key: 'count', name: 'Count', type: 'number', default: '10' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'name', + name: 'Name', + type: 'string', + default: 'Default Name', + }), + createPromptVariable({ key: 'count', name: 'Count', type: 'number', default: '10' }), + ]), + }) render() @@ -646,13 +578,11 @@ describe('ChatUserInput', () => { }) it('should not set default when default is empty string', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: '' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', default: '' }), + ]), + }) render() @@ -660,13 +590,11 @@ describe('ChatUserInput', () => { }) it('should not set default when default is undefined', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + }) render() @@ -674,18 +602,16 @@ describe('ChatUserInput', () => { }) it('should not set default when default is null', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ - key: 'name', - name: 'Name', - type: 'string', - default: null as unknown as string, - }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ + key: 'name', + name: 'Name', + type: 'string', + default: null as unknown as string, + }), + ]), + }) render() @@ -695,13 +621,11 @@ describe('ChatUserInput', () => { describe('Input constraints', () => { it('preserves the text length constraint', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string', max_length: 50 }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string', max_length: 50 }), + ]), + }) render() @@ -712,13 +636,11 @@ describe('ChatUserInput', () => { describe('Edge Cases', () => { it('should show no options when a select has no configured choices', async () => { const user = userEvent.setup() - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: [] }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: [] }), + ]), + }) render() const select = screen.getByRole('combobox', { name: 'Choice' }) @@ -727,14 +649,12 @@ describe('ChatUserInput', () => { }) it('should preserve other input values when updating one field', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), + ]), + }) render() fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { @@ -748,26 +668,22 @@ describe('ChatUserInput', () => { }) it('should convert non-string values to string for display', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'value', name: 'Value', type: 'string' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'value', name: 'Value', type: 'string' }), + ]), + }) render() expect(screen.getByRole('textbox', { name: 'Value' })).toHaveValue('123') }) it('should not hide label for checkbox type', () => { - mockUseContext.mockReturnValue( - createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'enabled', name: 'Is Enabled', type: 'checkbox' }), - ]), - }), - ) + configuration = createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'enabled', name: 'Is Enabled', type: 'checkbox' }), + ]), + }) render() // For checkbox, the label is rendered inside BoolInput, not in the header diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx index 58e4368fb115df..46e9888358b2b1 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx @@ -79,14 +79,6 @@ vi.mock('@/app/components/app/configuration/toast', () => ({ }, })) -vi.mock('@/app/components/base/chat/utils', () => ({ - getLastAnswer: (chatList: ChatItemType[]) => chatList.find((item) => item.isAnswer), -})) - -vi.mock('@/utils', () => ({ - canFindTool: (collectionId: string, providerId: string) => collectionId === providerId, -})) - vi.mock('@/app/components/base/chat/chat', () => ({ default: (props: typeof capturedChatProps) => { capturedChatProps = props diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx index 8d6b5d287ebca8..3f69a7f208d027 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx @@ -13,7 +13,6 @@ const mockModelListQuery = vi.fn() const mockUseFeatures = vi.fn() const mockUseTextGeneration = vi.fn() const mockUseEventEmitterContextContext = vi.fn() -const mockPromptVariablesToUserInputsForm = vi.fn() const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) @@ -58,11 +57,6 @@ vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => mockUseEventEmitterContextContext(), })) -vi.mock('@/utils/model-config', () => ({ - promptVariablesToUserInputsForm: (...args: unknown[]) => - mockPromptVariablesToUserInputsForm(...args), -})) - vi.mock('@/app/components/app/text-generate/item', () => ({ default: (props: typeof capturedTextGenerationProps) => { capturedTextGenerationProps = props @@ -158,10 +152,6 @@ const createDefaultMocks = () => { }, }, }) - - mockPromptVariablesToUserInputsForm.mockReturnValue([ - { variable: 'name', label: 'Name', type: 'text-input', required: true }, - ]) } const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => { @@ -749,7 +739,14 @@ describe('TextGenerationItem', () => { }) describe('promptVariablesToUserInputsForm', () => { - it('should call promptVariablesToUserInputsForm with prompt_variables', () => { + it('sends the generated input schema with the completion request', () => { + const handleSend = vi.fn() + mockUseTextGeneration.mockReturnValue({ + completion: '', + handleSend, + isResponding: false, + messageId: null, + }) const promptVariables = [ { key: 'name', name: 'Name', type: 'string' }, { key: 'age', name: 'Age', type: 'number' }, @@ -776,7 +773,30 @@ describe('TextGenerationItem', () => { renderComponent() - expect(mockPromptVariablesToUserInputsForm).toHaveBeenCalledWith(promptVariables) + eventSubscriptionCallback?.({ + type: APP_CHAT_WITH_MULTIPLE_MODEL, + payload: { message: 'Generate text', files: [] }, + }) + expect(handleSend).toHaveBeenCalledWith( + 'apps/app-123/completion-messages', + expect.objectContaining({ + model_config: expect.objectContaining({ + user_input_form: [ + { + 'text-input': expect.objectContaining({ + variable: 'name', + label: 'Name', + required: true, + }), + }, + { + number: expect.objectContaining({ variable: 'age', label: 'Age', required: true }), + }, + ], + }), + }), + expect.any(Object), + ) }) }) }) diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts index a64de03dffa2c3..92d832937398ca 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts @@ -31,7 +31,6 @@ const mockFetchAppDetailDirect = vi.fn() const mockFetchDatasets = vi.fn() const mockFetchCollectionList = vi.fn() const mockFetchAndMergeValidCompletionParams = vi.fn() -const mockGetSelectedDatasetsMode = vi.fn() const mockToastError = vi.fn() const mockToastSuccess = vi.fn() const mockToastWarning = vi.fn() @@ -65,17 +64,6 @@ vi.mock('@/utils/completion-params', () => ({ mockFetchAndMergeValidCompletionParams(...args), })) -vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/utils', async () => { - const actual = await vi.importActual< - typeof import('@/app/components/workflow/nodes/knowledge-retrieval/utils') - >('@/app/components/workflow/nodes/knowledge-retrieval/utils') - - return { - ...actual, - getSelectedDatasetsMode: (...args: unknown[]) => mockGetSelectedDatasetsMode(...args), - } -}) - vi.mock('@/app/components/app/configuration/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), @@ -87,13 +75,6 @@ vi.mock('@/app/components/app/configuration/toast', () => ({ describe('useConfiguration utils', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSelectedDatasetsMode.mockReturnValue({ - allExternal: false, - allInternal: false, - inconsistentEmbeddingModel: false, - mixtureHighQualityAndEconomic: false, - mixtureInternalAndExternal: false, - }) }) it('should preserve persisted settings when loading and publishing without edits', async () => { @@ -810,14 +791,9 @@ describe('useConfiguration utils', () => { it('should keep named datasets and open rerank settings when the selection mode requires it', () => { const setDataSets = vi.fn() const setRerankSettingModalOpen = vi.fn() - const nextDataSets = [createDatasetFixture({ id: 'dataset-2', name: 'Dataset Two' })] - mockGetSelectedDatasetsMode.mockReturnValue({ - allExternal: true, - allInternal: false, - inconsistentEmbeddingModel: false, - mixtureHighQualityAndEconomic: false, - mixtureInternalAndExternal: false, - }) + const nextDataSets = [ + createDatasetFixture({ id: 'dataset-2', name: 'Dataset Two', provider: 'external' }), + ] const handleSelect = createDatasetSelectHandler({ currentRerankModel: 'rerank-1', diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index 52b49a8f138d16..d03214833a8f82 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -1,6 +1,7 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import { act, screen, waitFor } from '@testing-library/react' import Cookies from 'js-cookie' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import CommonLayoutError from '@/app/(commonLayout)/error' import ErrorBoundary from '@/app/components/base/error-boundary' import { DETAIL_SIDEBAR_COOKIE_NAME } from '@/app/components/detail-sidebar/cookie' @@ -45,20 +46,26 @@ const createSeededQueryClient = (appDetail: Partial = {}) => return queryClient } +const onUrlUpdate = vi.fn() + const renderHook = ( callback: () => ReturnType, appDetail?: Partial, ) => { const queryClient = createSeededQueryClient(appDetail) + const QueryWrapper = createQueryClientWrapper(queryClient) return { ...renderHookWithConsoleState(callback, { - wrapper: createQueryClientWrapper(queryClient), + wrapper: ({ children }) => ( + + {children} + + ), }), queryClient, } } -const mockSetSettingsDestination = vi.fn() const mockHandleMultipleModelConfigsChange = vi.fn() const mockFetchCollectionList = vi.fn() const mockFetchAppDetailDirect = vi.fn() @@ -77,23 +84,8 @@ type AdvancedPromptConfigOptions = { } let latestAdvancedPromptConfigOptions: AdvancedPromptConfigOptions | undefined -let mockTempStopState: string[] = [] let mockCurrentModelFeatures = ['vision'] let mockCurrentModelMode = ModelModeType.chat -vi.mock('ahooks', async () => { - const actual = await vi.importActual('ahooks') - - return { - ...actual, - useGetState: () => [ - mockTempStopState, - (value: string[]) => { - mockTempStopState = value - }, - () => mockTempStopState, - ], - } -}) vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') @@ -114,11 +106,6 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - vi.mock('@/hooks/use-breakpoints', () => ({ __esModule: true, default: () => 'desktop', @@ -243,7 +230,6 @@ describe('useConfiguration', () => { vi.clearAllMocks() Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME) latestAdvancedPromptConfigOptions = undefined - mockTempStopState = [] mockCurrentModelFeatures = ['vision'] mockCurrentModelMode = ModelModeType.chat mockAppPermissionKeys = [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion] @@ -316,11 +302,13 @@ describe('useConfiguration', () => { try { renderHookWithConsoleState(() => useConfiguration(), { wrapper: ({ children }) => ( - -
{error.message}
}> - {children} -
-
+ + +
{error.message}
}> + {children} +
+
+
), }) expect(await screen.findByRole('alert')).toHaveTextContent( @@ -341,14 +329,16 @@ describe('useConfiguration', () => { try { renderHookWithConsoleState(() => useConfiguration(), { wrapper: ({ children }) => ( - - } - > - {children} - - + + + } + > + {children} + + + ), }) await waitFor(() => expect(onError).toHaveBeenCalledWith(response, expect.anything())) @@ -772,7 +762,9 @@ describe('useConfiguration', () => { expect(mockFormattingChangedDispatcher).toHaveBeenCalled() expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalled() expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse') - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'), + ) expect(mockSetConversationHistoriesRole).toHaveBeenCalledWith({ assistant_prefix: 'bot', user_prefix: 'user', @@ -851,6 +843,8 @@ describe('useConfiguration', () => { act(() => { latestAdvancedPromptConfigOptions?.onUserChangedPrompt?.() latestAdvancedPromptConfigOptions?.setStop?.(['END']) + }) + act(() => { result.current.contextValue.setAnnotationConfig({ id: 'annotation-1', enabled: true, diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index a68faac6e49f43..2eabd2904291f2 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -19,15 +19,6 @@ const mockConsoleState = vi.hoisted(() => ({ const mockConsoleStateReader = vi.hoisted(() => vi.fn()) const mockCreateApp = vi.hoisted(() => vi.fn()) -vi.mock('ahooks', () => ({ - useDebounceFn: unknown>(fn: T) => { - const run = (...args: Parameters) => fn(...args) - const cancel = vi.fn() - const flush = vi.fn() - return { run, cancel, flush } - }, - useHover: () => false, -})) vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), useParams: () => ({}), diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index 6c4db5dfdfd840..e294cab80054eb 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -26,11 +26,6 @@ let appCount = 0 let appLimit = 10 let mockWorkspacePermissionKeys: string[] = ['app.create_and_management'] const mockUserProfile = { id: 'user-1' } -vi.mock('ahooks', () => ({ - useDebounceFn: (fn: (...args: unknown[]) => unknown) => ({ - run: fn, - }), -})) vi.mock('@/utils/create-app-tracking', () => ({ trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), diff --git a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx index 7e5aba77fc54c6..6ff956a842b13c 100644 --- a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx +++ b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx @@ -1,8 +1,9 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' -import { screen, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createConsoleQueryWrapper } from '@/test/console/query-data' -import { render } from '@/test/console/render' +import { render as renderWithoutUrlState } from '@/test/console/render' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { ArchivedLogsNotice } from '../archived-logs-notice' vi.mock('@/context/workspace-state', async () => { @@ -13,14 +14,11 @@ vi.mock('@/context/workspace-state', async () => { })) }) -const setSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, setSettingsDestination] } -}) - let plan: CloudPlan = 'professional' +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('ArchivedLogsNotice', () => { const renderNotice = () => { const { wrapper } = createConsoleQueryWrapper({ @@ -45,7 +43,11 @@ describe('ArchivedLogsNotice', () => { expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument() await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' })) - expect(setSettingsDestination).toHaveBeenCalledWith('workflow-log-archives') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe( + 'workflow-log-archives', + ), + ) }) it('should not show notice for sandbox workspaces', () => { diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index f47249346dcb09..a05538dc9787bf 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -2,14 +2,16 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' import type { ReactElement } from 'react' import type { QueryParam } from '../index' -import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { consoleQuery } from '@/service/console' import { createConsoleQueryClient, renderWithConsoleQuery, seedFeatures, } from '@/test/console/query-data' -import Filter, { TIME_PERIOD_MAPPING } from '../filter' +import Filter from '../filter' let mockAnnotationsCountLoading = false let mockAnnotationsCountData: { count: number } | null = { count: 10 } @@ -51,382 +53,128 @@ const render = (ui: ReactElement) => { }) } -vi.mock('@/app/components/base/chip', async () => { - const { useState } = await import('react') +const initialQuery: QueryParam = { period: '2', annotation_status: 'all', keyword: '' } +const onChange = vi.fn() +function ControlledFilter({ isChatMode = true }: { isChatMode?: boolean }) { + const [queryParams, setQueryParams] = useState(initialQuery) + return ( + { + onChange(value) + setQueryParams(value) + }} + /> + ) +} - return { - default: function MockChip({ - items, - value, - onSelect, - onClear, - }: { - items: Array<{ value: string; name: string }> - value?: string - onSelect: (item: { value: string; name: string }) => void - onClear: () => void - }) { - const [isOpen, setIsOpen] = useState(false) - const currentItem = items.find((item) => item.value === value) ?? items[0] - return ( -
-
{currentItem?.name}
- - {isOpen && ( -
    - {items.map((item) => ( -
  • {item.name}
  • - ))} -
- )} - - -
- ) - }, - } +beforeEach(() => { + vi.clearAllMocks() + mockAnnotationsCountLoading = false + mockAnnotationsCountData = { count: 10 } + scenario.deploymentEdition = 'CLOUD' + scenario.pending = false + scenario.planType = 'professional' }) -vi.mock('@/app/components/base/sort', () => ({ - default: ({ onSelect }: { onSelect: (value: string) => void }) => ( - - ), -})) - -describe('Filter', () => { - const defaultQueryParams: QueryParam = { - period: '9', - annotation_status: 'all', - keyword: '', - } - - const mockSetQueryParams = vi.fn() - const defaultProps = { - appId: 'test-app-id', - queryParams: defaultQueryParams, - setQueryParams: mockSetQueryParams, - } +it('restricts Cloud sandbox periods to the retained log window', async () => { + scenario.planType = 'sandbox' + const user = userEvent.setup() + render() + await user.click(screen.getByRole('combobox', { name: /last7days/ })) + const options = within(await screen.findByRole('listbox')).getAllByRole('option') + expect(options.map((item) => item.textContent)).toEqual([ + expect.stringMatching(/filter.period.today/), + expect.stringMatching(/filter.period.last7days/), + expect.stringMatching(/filter.period.last30days/), + ]) +}) - beforeEach(() => { - vi.clearAllMocks() - mockAnnotationsCountLoading = false - mockAnnotationsCountData = { count: 10 } - scenario.deploymentEdition = 'CLOUD' - scenario.pending = false - scenario.planType = 'professional' +it('keeps the period list restricted while the plan is pending and follows cache updates', async () => { + scenario.pending = true + const user = userEvent.setup() + const { queryClient } = render() + await user.click(screen.getByRole('combobox', { name: /last7days/ })) + const options = within(await screen.findByRole('listbox')) + expect(options.getAllByRole('option')).toHaveLength(3) + act(() => { + seedFeatures(queryClient, { billing: { subscription: { plan: 'professional' } } }) }) - - describe('Rendering', () => { - it('should render filter components', () => { - render() - - expect( - screen.getByRole('searchbox', { name: /(?:^|\.)operation\.search(?=$|:)/ }), - )!.toBeInTheDocument() - }) - - it('should return null when loading', () => { - mockAnnotationsCountLoading = true - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('should render sort component in chat mode', () => { - render() - - expect( - screen.getByRole('searchbox', { name: /(?:^|\.)operation\.search(?=$|:)/ }), - )!.toBeInTheDocument() - }) - - it('should not render sort component when not in chat mode', () => { - render() - - expect( - screen.getByRole('searchbox', { name: /(?:^|\.)operation\.search(?=$|:)/ }), - )!.toBeInTheDocument() - }) + await waitFor(() => expect(options.getAllByRole('option')).toHaveLength(9)) + act(() => { + seedFeatures(queryClient, { billing: { subscription: { plan: 'sandbox' } } }) }) + await waitFor(() => expect(options.getAllByRole('option')).toHaveLength(3)) +}) - describe('TIME_PERIOD_MAPPING', () => { - it('should have correct period keys', () => { - expect(Object.keys(TIME_PERIOD_MAPPING)).toEqual([ - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - ]) - }) - - it('should have today period with value 0', () => { - expect(TIME_PERIOD_MAPPING['1']!.value).toBe(0) - expect(TIME_PERIOD_MAPPING['1']!.name).toBe('today') - }) - - it('should have last7days period with value 7', () => { - expect(TIME_PERIOD_MAPPING['2']!.value).toBe(7) - expect(TIME_PERIOD_MAPPING['2']!.name).toBe('last7days') - }) - - it('should have last4weeks period with value 28', () => { - expect(TIME_PERIOD_MAPPING['3']!.value).toBe(28) - expect(TIME_PERIOD_MAPPING['3']!.name).toBe('last4weeks') - }) - - it('should have allTime period with value -1', () => { - expect(TIME_PERIOD_MAPPING['9']!.value).toBe(-1) - expect(TIME_PERIOD_MAPPING['9']!.name).toBe('allTime') - }) - }) - - describe('User Interactions', () => { - it('should only show supported periods for Cloud sandbox workspaces', () => { - scenario.deploymentEdition = 'CLOUD' - scenario.planType = 'sandbox' - - render() - - fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) - - const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) - expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ - expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/), - expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/), - expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/), - ]) - }) - - it('should keep periods restricted until the Cloud plan resolves, then follow cache updates', async () => { - scenario.pending = true - - const { queryClient } = render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) - - const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) - expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ - expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/), - expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/), - expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/), - ]) - - act(() => { - seedFeatures(queryClient, { billing: { subscription: { plan: 'professional' } } }) - }) - await waitFor(() => expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)) - - act(() => { - seedFeatures(queryClient, { billing: { subscription: { plan: 'sandbox' } } }) - }) - await waitFor(() => expect(periodOptions.getAllByRole('listitem')).toHaveLength(3)) - }) - - it.each(['COMMUNITY', 'ENTERPRISE'] as const)( - 'should keep all periods for sandbox workspaces in %s', - (edition) => { - scenario.deploymentEdition = edition - scenario.planType = 'sandbox' - - render() - - fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) - - const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) - expect(periodOptions.getAllByRole('listitem')).toHaveLength(9) - }, - ) - - it('should reset the Cloud sandbox period to today when cleared', () => { - scenario.deploymentEdition = 'CLOUD' - scenario.planType = 'sandbox' - - render() - - fireEvent.click(screen.getAllByText('clear-chip')[0]!) - - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - period: '1', - }) - }) - - it('should update keyword when typing in search input', () => { - render() - - const searchInput = screen.getByRole('searchbox', { - name: /(?:^|\.)operation\.search(?=$|:)/, - }) - fireEvent.change(searchInput, { target: { value: 'test search' } }) - - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - keyword: 'test search', - }) - }) - - it('should clear keyword when clear button is clicked', () => { - const propsWithKeyword = { - ...defaultProps, - queryParams: { ...defaultQueryParams, keyword: 'existing search' }, - } - - render() - - const clearButton = screen.getByRole('button', { name: /(?:^|\.)operation\.clear(?=$|:)/ }) - fireEvent.click(clearButton) - - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - keyword: '', - }) - }) - - it('should update and clear period, annotation, and sort filters', () => { - render() - - fireEvent.click(screen.getAllByText('select-9')[0]!) - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - period: '9', - }) - - fireEvent.click(screen.getAllByText('clear-chip')[0]!) - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - period: '9', - }) - - fireEvent.click(screen.getAllByText('select-not_annotated')[0]!) - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - annotation_status: 'not_annotated', - }) +it.each(['COMMUNITY', 'ENTERPRISE'] as const)('keeps every period in %s', async (edition) => { + scenario.deploymentEdition = edition + scenario.planType = 'sandbox' + const user = userEvent.setup() + render() + await user.click(screen.getByRole('combobox', { name: /last7days/ })) + expect(within(await screen.findByRole('listbox')).getAllByRole('option')).toHaveLength(9) +}) - fireEvent.click(screen.getAllByText('clear-chip')[1]!) - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - annotation_status: 'all', - }) +it.each([ + ['sandbox', '1', /today/], + ['professional', '9', /allTime/], +] as const)('clears the period to the permitted default for %s', async (plan, period, label) => { + scenario.planType = plan + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /operation.clear.*last7days/ })) + expect(onChange).toHaveBeenLastCalledWith({ ...initialQuery, period }) + expect(screen.getByRole('combobox', { name: label })).toBeInTheDocument() +}) - fireEvent.click(screen.getByText('select-sort')) - expect(mockSetQueryParams).toHaveBeenCalledWith({ - ...defaultQueryParams, - sort_by: '-updated_at', - }) - }) +it('changes period and annotation through real selectors while preserving the other filters', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('combobox', { name: /last7days/ })) + await user.click(await screen.findByRole('option', { name: /allTime/ })) + expect(onChange).toHaveBeenLastCalledWith({ ...initialQuery, period: '9' }) + await user.click(screen.getByRole('combobox', { name: /annotation.all/ })) + await user.click(await screen.findByRole('option', { name: /annotation.annotated/ })) + expect(onChange).toHaveBeenLastCalledWith({ + ...initialQuery, + period: '9', + annotation_status: 'annotated', }) + expect(screen.getByRole('combobox', { name: /annotation.annotated.*10/ })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /operation.clear.*annotation.annotated/ })) + expect(onChange).toHaveBeenLastCalledWith({ ...initialQuery, period: '9' }) +}) - describe('Query Params', () => { - it('should display "today" when period is set to 1', () => { - const propsWithPeriod = { - ...defaultProps, - queryParams: { ...defaultQueryParams, period: '1' }, - } - - render() - - // Period '1' maps to 'today' in TIME_PERIOD_MAPPING - // Period '1' maps to 'today' in TIME_PERIOD_MAPPING - expect(screen.getByText(/(?:^|\.)filter\.period\.today(?=$|:)/))!.toBeInTheDocument() - }) - - it('should display "last7days" when period is set to 2', () => { - const propsWithPeriod = { - ...defaultProps, - queryParams: { ...defaultQueryParams, period: '2' }, - } - - render() - - expect(screen.getByText(/(?:^|\.)filter\.period\.last7days(?=$|:)/))!.toBeInTheDocument() - }) - - it('should display "allTime" when period is set to 9', () => { - render() - - // Default period is '9' which maps to 'allTime' - // Default period is '9' which maps to 'allTime' - expect(screen.getByText(/(?:^|\.)filter\.period\.allTime(?=$|:)/))!.toBeInTheDocument() - }) - - it('should display annotated status with count when annotation_status is annotated', () => { - const propsWithAnnotation = { - ...defaultProps, - queryParams: { ...defaultQueryParams, annotation_status: 'annotated' as const }, - } - - render() - - // The mock returns count: 10, so the text should include the count - // The mock returns count: 10, so the text should include the count - expect(screen.getByText(/filter\.annotation\.annotated.*10/))!.toBeInTheDocument() - }) - - it('should display not_annotated status when annotation_status is not_annotated', () => { - const propsWithNotAnnotated = { - ...defaultProps, - queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' as const }, - } - - render() - - expect( - screen.getByText(/(?:^|\.)filter\.annotation\.not_annotated(?=$|:)/), - )!.toBeInTheDocument() - }) - - it('should display all annotation status when annotation_status is all', () => { - render() - - // Default annotation_status is 'all' - // Default annotation_status is 'all' - expect(screen.getByText(/(?:^|\.)filter\.annotation\.all(?=$|:)/))!.toBeInTheDocument() - }) - - it('should return null when annotation count data is unavailable', () => { - mockAnnotationsCountData = null - const { container } = render() - - expect(container.firstChild).toBeNull() - }) +it('combines text search, sort field and direction, then clears only the keyword', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByRole('searchbox'), 'failure') + await user.click(screen.getByRole('button', { name: /filter.sortBy/ })) + await user.click(await screen.findByRole('menuitemradio', { name: /header.updatedTime/ })) + await user.click(screen.getByRole('button', { name: /filter.descending/ })) + expect(onChange).toHaveBeenLastCalledWith({ + ...initialQuery, + keyword: 'failure', + sort_by: '-updated_at', }) + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) + expect(onChange).toHaveBeenLastCalledWith({ ...initialQuery, sort_by: '-updated_at' }) +}) - describe('Chat Mode', () => { - it('should display sort component with sort_by parameter', () => { - const propsWithSort = { - ...defaultProps, - isChatMode: true, - queryParams: { ...defaultQueryParams, sort_by: 'created_at' as const }, - } - - render() - - expect( - screen.getByRole('searchbox', { name: /(?:^|\.)operation\.search(?=$|:)/ }), - )!.toBeInTheDocument() - }) - - it('should handle descending sort order', () => { - const propsWithDescSort = { - ...defaultProps, - isChatMode: true, - queryParams: { ...defaultQueryParams, sort_by: '-created_at' as const }, - } - - render() - - expect( - screen.getByRole('searchbox', { name: /(?:^|\.)operation\.search(?=$|:)/ }), - )!.toBeInTheDocument() - }) - }) +it('omits chat sorting for completion logs', () => { + render() + expect(screen.queryByRole('button', { name: /filter.sortBy/ })).not.toBeInTheDocument() }) + +it.each(['loading', 'unavailable'] as const)( + 'does not expose filters while annotations are %s', + (state) => { + mockAnnotationsCountLoading = state === 'loading' + mockAnnotationsCountData = null + render() + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument() + }, +) diff --git a/web/app/components/app/log/__tests__/index.spec.tsx b/web/app/components/app/log/__tests__/index.spec.tsx index f02c21a0d779d1..66e35f5a7fe8a9 100644 --- a/web/app/components/app/log/__tests__/index.spec.tsx +++ b/web/app/components/app/log/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ import type { CloudSandboxPlanState } from '../cloud-sandbox-retention' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import dayjs from 'dayjs' import { APP_PAGE_LIMIT } from '@/config' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' import Logs from '../index' @@ -11,18 +12,13 @@ vi.mock('@/context/i18n', () => ({ })) const mockReplace = vi.fn() -const mockUseChatConversations = vi.fn() -const mockUseCompletionConversations = vi.fn() +const getChatConversations = vi.fn() +const getCompletionConversations = vi.fn() const mockPlanState = vi.hoisted(() => ({ value: 'unrestricted' as CloudSandboxPlanState, })) let mockSearchParams = new URLSearchParams() -vi.mock('ahooks', async () => { - return { - useDebounce: (value: T) => value, - } -}) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ @@ -35,33 +31,6 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@/service/console', () => { - const tagged = (operation: string) => ({ - queryOptions: (options: Record) => ({ ...options, operation }), - }) - return { - consoleQuery: { - apps: { - byAppId: { - chatConversations: { get: tagged('chatConversations') }, - completionConversations: { get: tagged('completionConversations') }, - annotations: { count: { get: tagged('annotationsCount') } }, - }, - }, - }, - } -}) - -vi.mock('@tanstack/react-query', async (importOriginal) => ({ - ...(await importOriginal()), - useQuery: ({ operation, ...options }: { operation?: string }) => { - if (operation === 'chatConversations') return mockUseChatConversations(options) - if (operation === 'completionConversations') return mockUseCompletionConversations(options) - if (operation === 'annotationsCount') return { data: { count: 0 }, isLoading: false } - return { data: undefined } - }, -})) - vi.mock('../cloud-sandbox-retention', async (importOriginal) => { const actual = await importOriginal() return { @@ -96,22 +65,16 @@ describe('Logs', () => { vi.clearAllMocks() mockSearchParams = new URLSearchParams() mockPlanState.value = 'unrestricted' - mockUseChatConversations.mockReturnValue({ - data: undefined, - refetch: vi.fn(), - }) - mockUseCompletionConversations.mockReturnValue({ - data: undefined, - refetch: vi.fn(), - }) + getChatConversations.mockReturnValue(new Promise(() => {})) + getCompletionConversations.mockReturnValue(new Promise(() => {})) }) it('should request chat conversations and show a loading state before data arrives', () => { render() - expect(mockUseChatConversations).toHaveBeenCalledWith( + expect(getChatConversations).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ params: { app_id: 'app-1' } }), + params: { app_id: 'app-1' }, }), ) expect(screen.getByRole('heading', { name: /(?:^|\.)title(?=$|:)/ })).toBeInTheDocument() @@ -123,31 +86,37 @@ describe('Logs', () => { expect(screen.getByText('loading-logs')).toBeInTheDocument() }) - it('should render the empty state for completion apps without logs', () => { - mockUseCompletionConversations.mockReturnValue({ - data: { total: 0 }, - refetch: vi.fn(), + it('should render the empty state for completion apps without logs', async () => { + getCompletionConversations.mockResolvedValue({ + total: 0, + data: [], + page: 1, + limit: APP_PAGE_LIMIT, + has_more: false, }) render() - expect(mockUseCompletionConversations).toHaveBeenCalledWith( + expect(getCompletionConversations).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ params: { app_id: 'app-2' } }), + params: { app_id: 'app-2' }, }), ) - expect(screen.getByText('empty-logs')).toBeInTheDocument() + expect(await screen.findByText('empty-logs')).toBeInTheDocument() }) - it('should update the page query parameter when pagination changes', () => { - mockUseChatConversations.mockReturnValue({ - data: { total: APP_PAGE_LIMIT + 1 }, - refetch: vi.fn(), + it('should update the page query parameter when pagination changes', async () => { + getChatConversations.mockResolvedValue({ + total: APP_PAGE_LIMIT + 1, + data: [], + page: 1, + limit: APP_PAGE_LIMIT, + has_more: false, }) render() - fireEvent.click(screen.getByRole('button', { name: 'Go to page 2' })) + fireEvent.click(await screen.findByRole('button', { name: 'Go to page 2' })) expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false }) }) @@ -155,9 +124,12 @@ describe('Logs', () => { it('should query the last 30 days when a Sandbox user selects the longest period', async () => { const user = userEvent.setup() mockPlanState.value = 'sandbox' - mockUseChatConversations.mockReturnValue({ - data: { total: 0 }, - refetch: vi.fn(), + getChatConversations.mockResolvedValue({ + total: 0, + data: [], + page: 1, + limit: APP_PAGE_LIMIT, + has_more: false, }) render( @@ -169,19 +141,19 @@ describe('Logs', () => { />, ) - await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ })) + await user.click( + await screen.findByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }), + ) await user.click(await screen.findByText(/appLog\.filter\.period\.last30days/)) expect( - screen.getByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }), + await screen.findByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }), ).toBeInTheDocument() - expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect(getChatConversations.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ - input: expect.objectContaining({ - query: expect.objectContaining({ - start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'), - end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), - }), + query: expect.objectContaining({ + start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'), + end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), }), }), ) @@ -193,22 +165,25 @@ describe('Logs', () => { id: 'app-period-transition', mode: 'chat' as const, }) - mockUseChatConversations.mockReturnValue({ - data: { total: 0 }, - refetch: vi.fn(), + getChatConversations.mockResolvedValue({ + total: 0, + data: [], + page: 1, + limit: APP_PAGE_LIMIT, + has_more: false, }) const unrestrictedRender = render() - await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ })) + await user.click( + await screen.findByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }), + ) await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/)) - expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect(getChatConversations.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ - input: expect.objectContaining({ - query: expect.not.objectContaining({ - start: expect.anything(), - end: expect.anything(), - }), + query: expect.not.objectContaining({ + start: expect.anything(), + end: expect.anything(), }), }), ) @@ -218,22 +193,33 @@ describe('Logs', () => { render() expect( - screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }), + await screen.findByRole('combobox', { name: /appLog\.filter\.period\.today/ }), ).toBeInTheDocument() expect( screen.getByRole('button', { name: /common\.operation\.clear appLog\.filter\.period\.today/, }), ).toBeInTheDocument() - expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect(getChatConversations.mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ - input: expect.objectContaining({ - query: expect.objectContaining({ - start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), - end: expect.any(String), - }), + query: expect.objectContaining({ + start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), + end: expect.any(String), }), }), ) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.chatConversations.get': (input) => getChatConversations(input), + 'apps.byAppId.completionConversations.get': (input) => getCompletionConversations(input), + 'apps.byAppId.annotations.count.get': () => Promise.resolve({ count: 0 }), + }), + } +}) diff --git a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx index d275c885b53eb2..77b1db7bbf6f23 100644 --- a/web/app/components/app/workflow-log/__tests__/detail.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx @@ -39,18 +39,6 @@ vi.mock('@/app/components/workflow/context', () => ({ ), })) -// Mock ahooks for useBoolean (used by TooltipPlus) -vi.mock('ahooks', () => ({ - useBoolean: (initial: boolean) => { - const setters = { - setTrue: vi.fn(), - setFalse: vi.fn(), - toggle: vi.fn(), - } - return [initial, setters] as const - }, -})) - describe('DetailPanel', () => { const defaultOnClose = vi.fn() let appId = 'test-app-id' diff --git a/web/app/components/app/workflow-log/__tests__/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx index babe4abd572634..0d2b0cd7b385c6 100644 --- a/web/app/components/app/workflow-log/__tests__/index.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx @@ -6,7 +6,6 @@ import type { WorkflowAppLogPartialResponse, WorkflowRunForLogResponse, } from '@dify/contracts/api/console/apps/types.gen' -import type { UseQueryResult } from '@tanstack/react-query' /** * Logs Container Component Tests * @@ -50,20 +49,7 @@ type WorkflowLogsInput = { } const mockedUseWorkflowLogs = - vi.fn<(input: WorkflowLogsInput) => UseQueryResult>() - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: (options: Parameters[0]) => { - const [path, meta] = options.queryKey as [string[], { input?: WorkflowLogsInput }?] - if (Array.isArray(path) && path.includes('workflowAppLogs') && meta?.input) - return mockedUseWorkflowLogs(meta.input) - return actual.useQuery(options) - }, - } -}) + vi.fn<(input: WorkflowLogsInput) => Promise>() vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => { const actual = await importOriginal() @@ -73,19 +59,6 @@ vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => { } }) -vi.mock('ahooks', () => ({ - useDebounce: (value: T) => value, - useDebounceFn: (fn: (value: string) => void) => ({ run: fn }), - useBoolean: (initial: boolean) => { - const setters = { - setTrue: vi.fn(), - setFalse: vi.fn(), - toggle: vi.fn(), - } - return [initial, setters] as const - }, -})) - vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -148,43 +121,6 @@ const renderWithQueryClient = (ui: React.ReactElement) => { // Mock Return Value Factory // ============================================================================ -const createMockQueryResult = ( - overrides: { data?: T; isLoading?: boolean; error?: Error | null } = {}, -): UseQueryResult => { - const isLoading = overrides.isLoading ?? false - const error = overrides.error ?? null - const data = overrides.data - - return { - data, - isLoading, - error, - refetch: vi.fn(), - isError: !!error, - isPending: isLoading, - isSuccess: !isLoading && !error && data !== undefined, - isFetching: isLoading, - isRefetching: false, - isLoadingError: false, - isRefetchError: false, - isInitialLoading: isLoading, - isPaused: false, - isEnabled: true, - status: isLoading ? 'pending' : error ? 'error' : 'success', - fetchStatus: isLoading ? 'fetching' : 'idle', - dataUpdatedAt: Date.now(), - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isFetched: !isLoading, - isFetchedAfterMount: !isLoading, - isPlaceholderData: false, - isStale: false, - promise: Promise.resolve(data as T), - } as UseQueryResult -} - // ============================================================================ // Test Data Factories // ============================================================================ @@ -266,13 +202,9 @@ describe('Logs Container', () => { // Rendering Tests (REQUIRED) // -------------------------------------------------------------------------- describe('Rendering', () => { - it('should render title and subtitle', () => { + it('should render title and subtitle', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) // Act renderWithQueryClient() @@ -282,13 +214,9 @@ describe('Logs Container', () => { expect(screen.getByText('appLog.workflowSubtitle')).toBeInTheDocument() }) - it('should render Filter component', () => { + it('should render Filter component', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) // Act renderWithQueryClient() @@ -303,14 +231,9 @@ describe('Logs Container', () => { // Loading State Tests // -------------------------------------------------------------------------- describe('Loading State', () => { - it('should show loading spinner when data is undefined', () => { + it('should show loading spinner when data is undefined', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: undefined, - isLoading: true, - }), - ) + mockedUseWorkflowLogs.mockReturnValue(new Promise(() => {})) // Act const { container } = renderWithQueryClient() @@ -319,19 +242,17 @@ describe('Logs Container', () => { expect(within(container).queryByRole('progressbar')).toBeInTheDocument() }) - it('should not show loading spinner when data is available', () => { + it('should not show loading spinner when data is available', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([createMockWorkflowLog()], 1)) // Act const { container } = renderWithQueryClient() // Assert - expect(within(container).queryByRole('progressbar')).not.toBeInTheDocument() + await waitFor(() => + expect(within(container).queryByRole('progressbar')).not.toBeInTheDocument(), + ) }) }) @@ -339,19 +260,15 @@ describe('Logs Container', () => { // Empty State Tests // -------------------------------------------------------------------------- describe('Empty State', () => { - it('should render empty element when total is 0', () => { + it('should render empty element when total is 0', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) // Act renderWithQueryClient() // Assert - expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument() + expect(await screen.findByText('appLog.table.empty.element.title')).toBeInTheDocument() expect(screen.queryByRole('table')).not.toBeInTheDocument() }) }) @@ -360,13 +277,9 @@ describe('Logs Container', () => { // Data Fetching Tests // -------------------------------------------------------------------------- describe('Data Fetching', () => { - it('should call useWorkflowLogs with correct appId and default params', () => { + it('should call useWorkflowLogs with correct appId and default params', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) // Act renderWithQueryClient() @@ -383,13 +296,9 @@ describe('Logs Container', () => { }) }) - it('should include date filters for non-allTime periods', () => { + it('should include date filters for non-allTime periods', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) // Act renderWithQueryClient() @@ -400,13 +309,9 @@ describe('Logs Container', () => { expect(callArg?.params).toHaveProperty('created_at__before') }) - it('should not include status param when status is all', () => { + it('should not include status param when status is all', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) // Act renderWithQueryClient() @@ -424,11 +329,7 @@ describe('Logs Container', () => { it('should update query when selecting status filter', async () => { // Arrange const user = userEvent.setup() - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) renderWithQueryClient() @@ -448,11 +349,7 @@ describe('Logs Container', () => { it('should update query when selecting period filter', async () => { // Arrange const user = userEvent.setup() - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) renderWithQueryClient() @@ -471,11 +368,7 @@ describe('Logs Container', () => { it('should query the last 30 days when a Sandbox user selects the longest period', async () => { const user = userEvent.setup() mockPlanState.value = 'sandbox' - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) renderWithQueryClient() @@ -494,11 +387,7 @@ describe('Logs Container', () => { it('should update query when typing keyword', async () => { // Arrange const user = userEvent.setup() - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) renderWithQueryClient() @@ -520,13 +409,9 @@ describe('Logs Container', () => { // Pagination Tests // -------------------------------------------------------------------------- describe('Pagination', () => { - it('should not render pagination when total is less than limit', () => { + it('should not render pagination when total is less than limit', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([createMockWorkflowLog()], 1)) // Act renderWithQueryClient() @@ -535,23 +420,19 @@ describe('Logs Container', () => { expect(screen.queryByRole('navigation')).not.toBeInTheDocument() }) - it('should render pagination when total exceeds limit', () => { + it('should render pagination when total exceeds limit', async () => { // Arrange const logs = Array.from({ length: APP_PAGE_LIMIT }, (_, i) => createMockWorkflowLog({ id: `log-${i}` }), ) - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse(logs, APP_PAGE_LIMIT + 10)) // Act renderWithQueryClient() // Assert - expect(screen.getByRole('table')).toBeInTheDocument() + expect(await screen.findByRole('table')).toBeInTheDocument() }) }) @@ -559,45 +440,39 @@ describe('Logs Container', () => { // List Rendering Tests // -------------------------------------------------------------------------- describe('List Rendering', () => { - it('should render List component when data is available', () => { + it('should render List component when data is available', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([createMockWorkflowLog()], 1)) // Act renderWithQueryClient() // Assert - expect(screen.getByRole('table')).toBeInTheDocument() + expect(await screen.findByRole('table')).toBeInTheDocument() }) - it('should display log data in table', () => { + it('should display log data in table', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse( - [ - createMockWorkflowLog({ - workflow_run: createMockWorkflowRun({ - status: 'succeeded', - total_tokens: 500, - }), + mockedUseWorkflowLogs.mockResolvedValue( + createMockLogsResponse( + [ + createMockWorkflowLog({ + workflow_run: createMockWorkflowRun({ + status: 'succeeded', + total_tokens: 500, }), - ], - 1, - ), - }), + }), + ], + 1, + ), ) // Act renderWithQueryClient() // Assert - expect(screen.getByText('appLog.status.succeeded')).toBeInTheDocument() - expect(screen.getByText('500')).toBeInTheDocument() + expect(await screen.findByText('appLog.status.succeeded')).toBeInTheDocument() + expect(await screen.findByText('500')).toBeInTheDocument() }) }) @@ -605,7 +480,7 @@ describe('Logs Container', () => { // TIME_PERIOD_MAPPING Export Tests // -------------------------------------------------------------------------- describe('TIME_PERIOD_MAPPING', () => { - it('should export TIME_PERIOD_MAPPING with correct values', () => { + it('should export TIME_PERIOD_MAPPING with correct values', async () => { expect(TIME_PERIOD_MAPPING['1']).toEqual({ value: 0, name: 'today' }) expect(TIME_PERIOD_MAPPING['2']).toEqual({ value: 7, name: 'last7days' }) expect(TIME_PERIOD_MAPPING['9']).toEqual({ value: -1, name: 'allTime' }) @@ -617,13 +492,9 @@ describe('Logs Container', () => { // Edge Cases (REQUIRED) // -------------------------------------------------------------------------- describe('Edge Cases', () => { - it('should handle different app modes', () => { + it('should handle different app modes', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([createMockWorkflowLog()], 1), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([createMockWorkflowLog()], 1)) const chatApp = createMockApp({ mode: 'advanced-chat' as AppMode }) @@ -634,14 +505,9 @@ describe('Logs Container', () => { expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument() }) - it('should handle error state from useWorkflowLogs', () => { + it('should handle error state from useWorkflowLogs', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: undefined, - error: new Error('Failed to fetch'), - }), - ) + mockedUseWorkflowLogs.mockRejectedValue(new Error('Failed to fetch')) // Act const { container } = renderWithQueryClient() @@ -650,13 +516,9 @@ describe('Logs Container', () => { expect(within(container).queryByRole('progressbar')).toBeInTheDocument() }) - it('should handle app with different ID', () => { + it('should handle app with different ID', async () => { // Arrange - mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ - data: createMockLogsResponse([], 0), - }), - ) + mockedUseWorkflowLogs.mockResolvedValue(createMockLogsResponse([], 0)) const customApp = createMockApp({ id: 'custom-app-123' }) @@ -669,3 +531,14 @@ describe('Logs Container', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.workflowAppLogs.get': (input) => mockedUseWorkflowLogs(input), + }), + } +}) diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index de02357a991822..2cb802d6689231 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -78,18 +78,6 @@ vi.mock('@/hooks/use-theme', () => ({ }, })) -// Mock ahooks -vi.mock('ahooks', () => ({ - useBoolean: (initial: boolean) => { - const setters = { - setTrue: vi.fn(), - setFalse: vi.fn(), - toggle: vi.fn(), - } - return [initial, setters] as const - }, -})) - // ============================================================================ // Test Data Factories // ============================================================================ diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 408810505cc550..a82dc8039c6466 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -144,19 +144,6 @@ vi.mock('@/app/notifications', () => ({ toast: toastMocks.api, })) -// Mock use-context-selector with stable toast reference for tracking calls -// Include createContext for components that use it (like Toast) -vi.mock('use-context-selector', () => ({ - createContext: (defaultValue: T) => React.createContext(defaultValue), - useContext: () => ({ - notify: toastMocks.api, - }), - useContextSelector: (_context: unknown, selector: (state: Record) => unknown) => - selector({ - notify: toastMocks.api, - }), -})) - const mockConsoleState = vi.hoisted(() => ({ userProfile: { id: 'user-1' }, workspacePermissionKeys: ['app.create_and_management'] as string[], @@ -185,10 +172,6 @@ vi.mock('@/context/permission-state', async () => { // systemFeatures is seeded into the QueryClient via the local render helper. -vi.mock('@/service/apps', () => ({ - deleteApp: vi.fn(() => Promise.resolve()), -})) - const { installedRequest } = vi.hoisted(() => ({ installedRequest: vi.fn<(url: string, init: RequestInit, options: { request: Request }) => Promise>(), diff --git a/web/app/components/apps/__tests__/creators-filter.spec.tsx b/web/app/components/apps/__tests__/creators-filter.spec.tsx index 44259dfe2e6c3c..d68103172b3fab 100644 --- a/web/app/components/apps/__tests__/creators-filter.spec.tsx +++ b/web/app/components/apps/__tests__/creators-filter.spec.tsx @@ -5,11 +5,13 @@ import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' import CreatorsFilter from '../creators-filter' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.members.get': () => ({ - data: { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.members.get': async () => ({ accounts: [ { id: 'member-1', name: 'Zoe', avatar_url: null, status: 'active' }, { id: 'member-2', name: 'Alice', avatar_url: null, status: 'active' }, @@ -18,9 +20,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { { id: 'member-5', name: 'Charlie', avatar_url: null, status: 'active' }, { id: 'member-6', name: 'Robin', avatar_url: null, status: 'active' }, ], - }, + }), }), - }) + } }) const mockOnChange = vi.hoisted(() => vi.fn()) @@ -118,13 +120,13 @@ describe('CreatorsFilter', () => { expect(mockOnChange).toHaveBeenCalledWith(['missing-member', 'member-3']) }) - it('should expose the selected creator count from the closed trigger', () => { + it('should expose the selected creator count from the closed trigger', async () => { render() const trigger = screen.getByRole('combobox', { name: 'app.studio.filters.creators' }) const selectedCount = within(trigger).getByText('common.dynamicSelect.selected:{"count":2}') expect(selectedCount).toHaveClass('sr-only') - expect(within(trigger).queryByText(/^\+\d+$/)).not.toBeInTheDocument() + await waitFor(() => expect(within(trigger).queryByText(/^\+\d+$/)).not.toBeInTheDocument()) }) it.each([ @@ -146,12 +148,12 @@ describe('CreatorsFilter', () => { }, ])( 'shows only the $remainingCount undisplayed creators in the overflow count', - ({ value, visibleInitials, remainingCount }) => { + async ({ value, visibleInitials, remainingCount }) => { render() const trigger = screen.getByRole('combobox', { name: 'app.studio.filters.creators' }) for (const initial of visibleInitials) { - expect(within(trigger).getByText(initial, { exact: true })).toBeInTheDocument() + expect(await within(trigger).findByText(initial, { exact: true })).toBeInTheDocument() } expect(within(trigger).queryByText('C', { exact: true })).not.toBeInTheDocument() expect(within(trigger).queryByText('R', { exact: true })).not.toBeInTheDocument() diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 5dd5432fbbe4cb..29af02587ccad9 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,20 +1,23 @@ +import type { AppPagination } from '@dify/contracts/api/console/apps/types.gen' import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen' import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' -import { keepPreviousData } from '@tanstack/react-query' -import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import * as React from 'react' import { stepByStepTourSessionAtom } from '@/app/components/step-by-step-tour/state' import { getStepByStepTourTargetSelector, STEP_BY_STEP_TOUR_TARGETS, } from '@/app/components/step-by-step-tour/target-registry' +import { consoleQuery } from '@/service/console' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createDeferred } from '@/test/deferred' import { List } from '../list' +import { appListQueryParsers } from '../query-params' vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') @@ -31,8 +34,21 @@ vi.mock('react-i18next', async () => { }) }) -const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) -const mockAppStarredListQueryOptions = vi.hoisted(() => vi.fn((options: unknown) => options)) +const { getApps, getStarredApps } = vi.hoisted(() => ({ + getApps: vi.fn(), + getStarredApps: vi.fn(), +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.get': getApps, + 'apps.starred.get': getStarredApps, + }), + } +}) const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) => ({ onlineUsersMap: {}, @@ -71,61 +87,6 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => mockSearchParams, })) -vi.mock('@/service/console', () => ({ - consoleClient: { - systemFeatures: { - get: vi.fn(), - }, - }, - consoleQuery: { - workspaces: { - current: { - members: { - get: { - queryOptions: (options: { input?: unknown }) => ({ - ...options, - queryKey: [['console', 'workspaces', 'current', 'members', 'get'], options], - }), - }, - }, - }, - }, - account: { - profile: { - get: { - queryKey: () => [['console', 'account', 'profile', 'get'], { type: 'query' }], - }, - }, - }, - apps: { - get: { - key: () => ['console', 'apps', 'get'], - infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options), - }, - starred: { - get: { - key: () => ['console', 'apps', 'starred', 'get'], - queryOptions: (options: unknown) => mockAppStarredListQueryOptions(options), - }, - }, - }, - tags: { - get: { - queryOptions: (options: unknown) => options, - }, - }, - systemFeatures: { - get: { - queryKey: () => ['console', 'systemFeatures', 'get'], - queryOptions: (options: Record = {}) => ({ - queryKey: ['console', 'systemFeatures', 'get'], - ...options, - }), - }, - }, - }, -})) - let mockWorkspacePermissionKeys = ['app.create_and_management'] vi.mock('@/context/permission-state', async () => { @@ -162,22 +123,11 @@ vi.mock('../hooks/use-workflow-online-users', () => ({ useWorkflowOnlineUsers: (options: unknown) => mockUseWorkflowOnlineUsers(options), })) -const mockFetchNextPage = vi.fn() -let mockSystemFeatures: GetSystemFeaturesResponse | null = null - -const mockServiceState = { - error: null as Error | null, - hasNextPage: false, - isFetchNextPageError: false, - isFetching: false, - isLoading: false, - isFetchingNextPage: false, - isPlaceholderData: false, -} -let mockStarredIsLoading = false -let mockStarredError: Error | null = null +let hasMoreApps = false +let seedAppList = true +let seedStarredList = true -const defaultAppData = { +const defaultAppData: { pages: Array> } = { pages: [ { data: [ @@ -186,6 +136,7 @@ const defaultAppData = { name: 'Test App 1', description: 'Description 1', mode: 'chat', + icon_url: null, icon: '🤖', icon_type: 'emoji', icon_background: '#FFEAD5', @@ -198,7 +149,8 @@ const defaultAppData = { id: 'app-2', name: 'Test App 2', description: 'Description 2', - mode: 'workflow' as const, + mode: 'workflow', + icon_url: null, icon: '⚙️', icon_type: 'emoji', icon_background: '#E4FBCC', @@ -214,15 +166,7 @@ const defaultAppData = { } let mockAppData = defaultAppData -type MockStarredAppData = { - data: Array> - total: number - page: number - limit: number - has_more: boolean -} - -let mockStarredAppData: MockStarredAppData = { +let mockStarredAppData: AppPagination = { data: [], total: 0, page: 1, @@ -230,44 +174,6 @@ let mockStarredAppData: MockStarredAppData = { has_more: false, } -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: (options: { input?: unknown; queryKey?: unknown[] }) => - Array.isArray(options.queryKey?.[0]) && - options.queryKey[0].join('.') === 'console.workspaces.current.members.get' - ? (() => ({ - data: { - accounts: [ - { id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' }, - { id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' }, - ], - }, - }))() - : options.input - ? { - data: mockStarredIsLoading ? undefined : mockStarredAppData, - error: mockStarredError, - } - : { - data: mockSystemFeatures, - error: null, - }, - useSuspenseQuery: () => ({ data: mockSystemFeatures }), - useInfiniteQuery: () => ({ - data: mockServiceState.isLoading ? undefined : mockAppData, - isFetching: mockServiceState.isFetching, - isFetchNextPageError: mockServiceState.isFetchNextPageError, - isFetchingNextPage: mockServiceState.isFetchingNextPage, - isPlaceholderData: mockServiceState.isPlaceholderData, - fetchNextPage: mockFetchNextPage, - hasNextPage: mockServiceState.hasNextPage, - error: mockServiceState.error, - }), - } -}) - vi.mock('@/hooks/use-pay', () => ({ CheckResultToast: () => null, })) @@ -500,39 +406,80 @@ type RenderListOptions = { const renderList = (searchParams = '', options: RenderListOptions = {}) => { mockSearchParams = new URLSearchParams(searchParams) - const { wrapper: ConsoleQueryWrapper, systemFeatures } = createConsoleQueryWrapper({ + const { wrapper: ConsoleQueryWrapper, queryClient } = createConsoleQueryWrapper({ accountProfile: { id: 'creator-1' }, systemFeatures: { branding: { enabled: false }, ...options.systemFeatures }, }) - mockSystemFeatures = systemFeatures + const category = + appListQueryParsers.category.parse(mockSearchParams.get('category') ?? '') ?? 'all' + const query = { + name: mockSearchParams.get('keywords') ?? '', + page: 1, + limit: 30, + sort_by: + appListQueryParsers.sort_by.parse(mockSearchParams.get('sort_by') ?? '') ?? 'last_modified', + ...(category !== 'all' ? { mode: category } : {}), + } + const appOptions = consoleQuery.apps.get.infiniteOptions({ + input: (page: number) => ({ query: { ...query, page } }), + initialPageParam: 1, + getNextPageParam: (last) => (last.has_more ? last.page + 1 : undefined), + }) + if (seedAppList && mockAppData.pages.length) + queryClient.setQueryData<{ pages: AppPagination[]; pageParams: number[] }>( + appOptions.queryKey, + { + pages: mockAppData.pages.map((page, index) => ({ + ...page, + page: index + 1, + limit: 30, + has_more: hasMoreApps, + })), + pageParams: [1], + }, + ) + if (seedStarredList) + queryClient.setQueryData( + consoleQuery.apps.starred.get.queryKey({ input: { query: { ...query, limit: 100 } } }), + mockStarredAppData, + ) + queryClient.setQueryData(consoleQuery.workspaces.current.members.get.queryKey({ input: {} }), { + accounts: [ + { + id: 'creator-1', + name: 'Alice', + avatar_url: null, + status: 'active', + email: 'alice@example.com', + role: 'admin', + }, + { + id: 'creator-2', + name: 'Bob', + avatar_url: null, + status: 'active', + email: 'bob@example.com', + role: 'normal', + }, + ], + }) const store = createStore() seedRegisteredConsoleStateFixture(store) store.set(stepByStepTourSessionAtom, stepByStepTourSessionState) - const rendered = renderWithNuqs( - - - - - , - { searchParams }, + const onUrlUpdate = vi.fn() + const rendered = render( + + + + + + + , ) - return rendered -} - -type AppListInfiniteOptions = { - input: (pageParam: number) => { query: Record } - getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined - placeholderData?: unknown -} - -type AppStarredListQueryOptions = { - input: { - query: Record - } - placeholderData?: unknown + return { ...rendered, onUrlUpdate, queryClient } } const openAppTypeSelect = async (user = userEvent.setup()) => { @@ -574,15 +521,11 @@ describe('List', () => { vi.clearAllMocks() stepByStepTourSessionState = {} mockWorkspacePermissionKeys = ['app.create_and_management'] - mockServiceState.error = null - mockServiceState.hasNextPage = false - mockServiceState.isFetchNextPageError = false - mockServiceState.isFetching = false - mockServiceState.isLoading = false - mockServiceState.isFetchingNextPage = false - mockServiceState.isPlaceholderData = false - mockStarredIsLoading = false - mockStarredError = null + hasMoreApps = false + seedAppList = true + seedStarredList = true + getApps.mockReset().mockImplementation(() => new Promise(() => {})) + getStarredApps.mockReset().mockImplementation(() => new Promise(() => {})) mockAppData = defaultAppData mockStarredAppData = { data: [], @@ -924,16 +867,6 @@ describe('List', () => { expect(screen.getByRole('combobox', { name: 'Types' }))!.toBeInTheDocument() }) - it('should not render first empty state from placeholder data during a filter transition', () => { - mockAppData = { pages: [{ data: [], total: 0 }] } - mockServiceState.isPlaceholderData = true - - renderList() - - expect(screen.getByTestId('empty-state')).toBeInTheDocument() - expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument() - }) - it('should keep the regular empty state for empty filtered results', () => { mockAppData = { pages: [{ data: [], total: 0 }] } @@ -951,26 +884,13 @@ describe('List', () => { expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument() }) - it('should keep the result status quiet while placeholder results are fetching', () => { - mockAppData = { pages: [{ data: [], total: 0 }] } - mockServiceState.isFetching = true - mockServiceState.isPlaceholderData = true - - renderList('?keywords=missing+app') - - expect(screen.getByRole('status')).toBeEmptyDOMElement() - expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute( - 'aria-busy', - 'true', - ) - }) - - it('should keep the settled empty status during a background refetch', () => { + it('keeps the settled empty status during a real background refresh', async () => { mockAppData = { pages: [{ data: [], total: 0 }] } - mockServiceState.isFetching = true - - renderList('?keywords=missing+app') - + const { queryClient } = renderList('?keywords=missing+app') + act(() => { + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) + }) + await waitFor(() => expect(getApps).toHaveBeenCalledOnce()) expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps') expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute( 'aria-busy', @@ -1048,12 +968,13 @@ describe('List', () => { ) }) - it('should reject API modes that the Studio category control does not support', () => { + it('should reject API modes that the Studio category control does not support', async () => { + seedAppList = false renderList('?category=channel') expect(screen.getByRole('combobox', { name: 'Types' })).toBeInTheDocument() - const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions - expect(options.input(1).query).not.toHaveProperty('mode') + await waitFor(() => expect(getApps).toHaveBeenCalledOnce()) + expect(getApps.mock.calls[0]![0].query).not.toHaveProperty('mode') }) it('should update category when workflow option is selected', async () => { @@ -1114,71 +1035,51 @@ describe('List', () => { expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) }) - it('should build paged query input from active filters', async () => { + it('requests main and starred results with current filters and their own page limits', async () => { const user = userEvent.setup() renderList('?keywords=sales&category=workflow') await user.click(screen.getByRole('combobox', { name: 'Creators' })) await user.click(screen.getByRole('option', { name: /Alice/ })) await user.click(screen.getByText('common.tag.placeholder')) - - const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions - - expect(options.input(2)).toEqual({ - query: { - page: 2, - limit: 30, - name: 'sales', - sort_by: 'last_modified', - tag_ids: ['tag-1'], - creator_ids: ['creator-1'], - mode: 'workflow' as const, - }, - }) - expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3) - expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined() - }) - - it('should build starred query input from active filters with the starred limit', async () => { - const user = userEvent.setup() - renderList('?keywords=sales&category=workflow') - await user.click(screen.getByRole('combobox', { name: 'Creators' })) - await user.click(screen.getByRole('option', { name: /Alice/ })) - await user.click(screen.getByText('common.tag.placeholder')) - - const options = mockAppStarredListQueryOptions.mock.calls.at( - -1, - )?.[0] as AppStarredListQueryOptions - - expect(options.input).toEqual({ - query: { - page: 1, - limit: 100, - name: 'sales', - sort_by: 'last_modified', - tag_ids: ['tag-1'], - creator_ids: ['creator-1'], - mode: 'workflow' as const, - }, - }) + const filters = { + page: 1, + name: 'sales', + sort_by: 'last_modified', + tag_ids: ['tag-1'], + creator_ids: ['creator-1'], + mode: 'workflow', + } + await waitFor(() => + expect(getApps).toHaveBeenLastCalledWith({ query: { ...filters, limit: 30 } }), + ) + expect(getStarredApps).toHaveBeenLastCalledWith({ query: { ...filters, limit: 100 } }) }) - it('should keep previous main and starred data visible while filters refetch', async () => { + it('keeps main and starred results while new filters load, then replaces them', async () => { + mockStarredAppData = { + ...mockStarredAppData, + data: [{ ...defaultAppData.pages[0]!.data[0]!, id: 'starred-app', name: 'Starred app' }], + } + const nextApps = createDeferred() + const nextStarred = createDeferred() + getApps.mockReturnValue(nextApps.promise) + getStarredApps.mockReturnValue(nextStarred.promise) renderList() - - expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() - fireEvent.click(screen.getByText('common.tag.placeholder')) - - await waitFor(() => { - const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions - expect(options.input(1).query).toMatchObject({ tag_ids: ['tag-1'] }) - expect(options.placeholderData).toBe(keepPreviousData) - }) - const starredOptions = mockAppStarredListQueryOptions.mock.calls.at( - -1, - )?.[0] as AppStarredListQueryOptions - expect(starredOptions.placeholderData).toBe(keepPreviousData) + await waitFor(() => + expect(getApps).toHaveBeenCalledWith({ + query: expect.objectContaining({ tag_ids: ['tag-1'] }), + }), + ) expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() + expect(screen.getByText('Starred app')).toBeInTheDocument() + await act(async () => { + nextApps.resolve({ data: [], page: 1, limit: 30, total: 0, has_more: false }) + nextStarred.resolve({ data: [], page: 1, limit: 100, total: 0, has_more: false }) + }) + await waitFor(() => expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()) + expect(screen.queryByText('Starred app')).not.toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps') }) }) @@ -1262,12 +1163,15 @@ describe('List', () => { it.each([ ['earliest_created', 'earliest_created', 'Earliest created'], ['invalid', 'last_modified', 'Last modified'], - ])('should resolve URL sort %s for the control and query', (urlSort, expectedSort, label) => { + ])('resolves URL sort %s for the control and request', async (urlSort, expectedSort, label) => { + seedAppList = false renderList(`?sort_by=${urlSort}`) - expect(screen.getByRole('combobox', { name: 'Sort by' })).toHaveTextContent(label) - const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions - expect(options.input(1).query.sort_by).toBe(expectedSort) + await waitFor(() => + expect(getApps).toHaveBeenCalledWith({ + query: expect.objectContaining({ sort_by: expectedSort }), + }), + ) }) it('should update URL and query sorting while preserving filters and resetting scroll', async () => { @@ -1280,8 +1184,15 @@ describe('List', () => { await user.click(await screen.findByRole('option', { name: 'Recently created' })) - const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions - expect(options.input(1).query.sort_by).toBe('recently_created') + await waitFor(() => + expect(getApps).toHaveBeenLastCalledWith({ + query: expect.objectContaining({ + sort_by: 'recently_created', + mode: 'workflow', + name: 'sales', + }), + }), + ) expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) await waitFor(() => { const params = onUrlUpdate.mock.calls.at(-1)?.[0].searchParams @@ -1338,28 +1249,31 @@ describe('List', () => { }) describe('Infinite Scroll', () => { - it('should call fetchNextPage when intersection observer triggers', async () => { - mockServiceState.hasNextPage = true - renderList() - - await waitFor(() => { - expect(mockObserve).toHaveBeenCalled() + it('requests and appends the next page when the sentinel intersects', async () => { + hasMoreApps = true + getApps.mockResolvedValue({ + data: [{ ...defaultAppData.pages[0]!.data[0]!, id: 'app-3', name: 'Third app' }], + page: 2, + limit: 30, + total: 3, + has_more: false, }) - - for (const callback of intersectionCallbacks) { - act(() => { + renderList() + await waitFor(() => expect(mockObserve).toHaveBeenCalled()) + act(() => { + for (const callback of [...intersectionCallbacks]) callback( [{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver, ) - }) - } - - expect(mockFetchNextPage).toHaveBeenCalled() + }) + expect(await screen.findByTestId('app-card-app-3')).toBeInTheDocument() + expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() + expect(getApps).toHaveBeenCalledWith({ query: expect.objectContaining({ page: 2 }) }) }) it('should not call fetchNextPage when not intersecting', () => { - mockServiceState.hasNextPage = true + hasMoreApps = true renderList() for (const callback of intersectionCallbacks) { @@ -1371,56 +1285,64 @@ describe('List', () => { }) } - expect(mockFetchNextPage).not.toHaveBeenCalled() + expect(getApps).not.toHaveBeenCalled() }) - it('should show one catalog skeleton until both app queries have data', () => { - mockServiceState.hasNextPage = true - mockServiceState.isLoading = true - const mainPending = renderList() - - expect( - screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }), - ).toBeInTheDocument() - expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) - expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument() - expect(mockFetchNextPage).not.toHaveBeenCalled() - - mainPending.unmount() - mockServiceState.isLoading = false - mockStarredIsLoading = true + it('shows one skeleton until both requests have resolved', async () => { + seedAppList = false + seedStarredList = false + const main = createDeferred() + const starred = createDeferred() + getApps.mockReturnValue(main.promise) + getStarredApps.mockReturnValue(starred.promise) renderList() - + expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) + await act(async () => + main.resolve({ ...defaultAppData.pages[0], page: 1, limit: 30, has_more: false }), + ) expect(screen.getAllByRole('status', { name: 'common.loading' })).toHaveLength(1) expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument() + await act(async () => starred.resolve(mockStarredAppData)) + expect(await screen.findByTestId('app-card-app-1')).toBeInTheDocument() + expect(screen.queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument() }) }) describe('Error State', () => { - it('should keep the main catalog available when the starred request fails', () => { - mockStarredIsLoading = true - mockStarredError = new Error('Starred request failed') - + it('keeps the main catalog available when the starred request fails', async () => { + seedStarredList = false + getStarredApps.mockRejectedValue(new Error('Starred request failed')) renderList() - - expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() + expect(await screen.findByTestId('app-card-app-1')).toBeInTheDocument() expect(screen.queryByText('Starred')).not.toBeInTheDocument() expect(screen.queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument() }) - it('should keep resolved data visible and retry a failed next page', async () => { + it('keeps resolved results and retries the failed next page', async () => { const user = userEvent.setup() - mockServiceState.error = new Error('Test error') - mockServiceState.hasNextPage = true - mockServiceState.isFetchNextPageError = true - + hasMoreApps = true + getApps.mockRejectedValueOnce(new Error('Next page failed')).mockResolvedValue({ + data: [{ ...defaultAppData.pages[0]!.data[0]!, id: 'app-3' }], + page: 2, + limit: 30, + total: 3, + has_more: false, + }) renderList() - + await waitFor(() => expect(mockObserve).toHaveBeenCalled()) + act(() => { + for (const callback of [...intersectionCallbacks]) + callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + const retryButton = await screen.findByRole('button', { name: 'common.operation.retry' }) expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument() - const retryButton = screen.getByRole('button', { name: 'common.operation.retry' }) await user.click(retryButton) - - expect(mockFetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false }) + expect(await screen.findByTestId('app-card-app-3')).toBeInTheDocument() + expect(getApps).toHaveBeenCalledTimes(2) + expect(getApps).toHaveBeenLastCalledWith({ query: expect.objectContaining({ page: 2 }) }) }) }) }) diff --git a/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts index 78c596f7d64f0d..9ec944a4983147 100644 --- a/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts +++ b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts @@ -1,166 +1,74 @@ -import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app' -import { renderHook } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' +import { renderHookWithConsoleQuery } from '@/test/console/query-data' import { useWorkflowOnlineUsers } from '../use-workflow-online-users' -type QueryOptions = { - input: { - body: { - app_ids: string[] - } +const getUsers = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.workflows.onlineUsers.post': getUsers, + }), } - enabled: boolean - select: (response?: WorkflowOnlineUsersResponse) => Record - refetchInterval: false | number -} - -const { mockUseQuery, mockQueryOptions } = vi.hoisted(() => ({ - mockUseQuery: vi.fn(), - mockQueryOptions: vi.fn((options: QueryOptions) => options), -})) - -vi.mock('@tanstack/react-query', () => ({ - useQuery: mockUseQuery, -})) - -vi.mock('@/service/console', () => ({ - consoleQuery: { - apps: { - workflows: { - onlineUsers: { - post: { - queryOptions: mockQueryOptions, - }, - }, - }, - }, - }, -})) - -const getLastQueryOptions = () => { - const lastCall = mockQueryOptions.mock.lastCall - if (!lastCall) throw new Error('workflows.onlineUsers.post.queryOptions was not called') - return lastCall[0] -} +}) +const user = { user_id: 'user-1', username: 'Alice' } describe('useWorkflowOnlineUsers', () => { beforeEach(() => { - vi.clearAllMocks() - mockUseQuery.mockReturnValue({ data: undefined }) + getUsers.mockReset().mockResolvedValue({ data: {} }) }) - - describe('Query Options', () => { - it('should disable query with a valid empty input when app ids are empty', () => { - renderHook(() => - useWorkflowOnlineUsers({ - appIds: [], - enabled: true, - }), - ) - - expect(mockQueryOptions).toHaveBeenCalledWith( - expect.objectContaining({ - input: { body: { app_ids: [] } }, - enabled: false, - refetchInterval: false, - }), - ) - expect(mockUseQuery).toHaveBeenCalledWith( - expect.objectContaining({ - input: { body: { app_ids: [] } }, - enabled: false, - }), - ) - }) - - it('should enable query and polling when collaboration is enabled with app ids', () => { - renderHook(() => - useWorkflowOnlineUsers({ - appIds: ['app-1', 'app-2'], - enabled: true, - }), - ) - - expect(mockQueryOptions).toHaveBeenCalledWith( - expect.objectContaining({ - input: { body: { app_ids: ['app-1', 'app-2'] } }, - enabled: true, - refetchInterval: 10000, - }), - ) - }) - - it('should disable query while preserving valid input when collaboration is disabled', () => { - renderHook(() => - useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: false, - }), - ) - - expect(mockQueryOptions).toHaveBeenCalledWith( - expect.objectContaining({ - input: { body: { app_ids: ['app-1'] } }, - enabled: false, - refetchInterval: false, - }), - ) - }) + afterEach(() => { + vi.useRealTimers() }) - describe('Response Mapping', () => { - it('should normalize array response data by app id', () => { - renderHook(() => - useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: true, - }), - ) - - const options = getLastQueryOptions() - const user = { user_id: 'user-1', username: 'Alice' } - - expect( - options.select({ - data: [{ app_id: 'app-1', users: [user] }], - }), - ).toEqual({ - 'app-1': [user], - }) - }) - - it('should normalize record response data without changing user arrays', () => { - renderHook(() => - useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: true, - }), - ) - - const options = getLastQueryOptions() - const users = [{ user_id: 'user-1', username: 'Alice' }] - - expect( - options.select({ - data: { - 'app-1': users, - }, - }), - ).toEqual({ - 'app-1': users, - }) - }) + it.each([ + { appIds: [], enabled: true }, + { appIds: ['app-1'], enabled: false }, + ])('does not request users when collaboration is unavailable (%j)', async (params) => { + const { result } = renderHookWithConsoleQuery(() => useWorkflowOnlineUsers(params)) + await act(async () => {}) + expect(getUsers).not.toHaveBeenCalled() + expect(result.current.onlineUsersMap).toEqual({}) }) - describe('Return Value', () => { - it('should return an empty map when query data is unavailable', () => { - const { result } = renderHook(() => - useWorkflowOnlineUsers({ - appIds: ['app-1'], - enabled: true, - }), + it.each([{ data: [{ app_id: 'app-1', users: [user] }] }, { data: { 'app-1': [user] } }])( + 'normalizes supported response shapes through the query selector (%j)', + async (response) => { + getUsers.mockResolvedValue(response) + const { result } = renderHookWithConsoleQuery(() => + useWorkflowOnlineUsers({ appIds: ['app-1'], enabled: true }), ) - expect(result.current.onlineUsersMap).toEqual({}) + await waitFor(() => expect(result.current.onlineUsersMap).toEqual({ 'app-1': [user] })) + expect(getUsers).toHaveBeenCalledWith({ body: { app_ids: ['app-1'] } }) + }, + ) + + it('polls every ten seconds and stops polling when collaboration is disabled', async () => { + vi.useFakeTimers() + const { result, rerender } = renderHookWithConsoleQuery( + ({ enabled }) => useWorkflowOnlineUsers({ appIds: ['app-1'], enabled }), + { initialProps: { enabled: true } }, + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(getUsers).toHaveBeenCalledTimes(1) + getUsers.mockResolvedValue({ data: { 'app-1': [user] } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(10000) + }) + expect(getUsers).toHaveBeenCalledTimes(2) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(result.current.onlineUsersMap).toEqual({ 'app-1': [user] }) + rerender({ enabled: false }) + await act(async () => { + await vi.advanceTimersByTimeAsync(20000) }) + expect(getUsers).toHaveBeenCalledTimes(2) }) }) diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index bdefde4e2d0cde..2c56f85ca90d61 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -1,6 +1,5 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { useClickAway } from 'ahooks' import AgentLogModal from '../index' const { mockToast } = vi.hoisted(() => { @@ -68,10 +67,6 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ default: () =>
, })) -vi.mock('ahooks', () => ({ - useClickAway: vi.fn(), -})) - const mockLog = { id: 'msg-id', conversationId: 'conv-id', @@ -170,46 +165,24 @@ describe('AgentLogModal', () => { expect(mockProps.onCancel).toHaveBeenCalledTimes(1) }) - it('should call onCancel when clicking away', () => { + it('should call onCancel when clicking outside the panel', () => { fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) - - let clickAwayHandler!: (event: Event) => void - vi.mocked(useClickAway).mockImplementation((callback) => { - clickAwayHandler = callback - }) - render() - clickAwayHandler(new Event('click')) - - expect(mockProps.onCancel).toHaveBeenCalledTimes(1) + fireEvent.click(document.body) + expect(mockProps.onCancel).toHaveBeenCalledOnce() }) - it('should ignore click-away before mounted state is set', () => { + it('does not close when clicking inside the panel', () => { fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) - let invoked = false - vi.mocked(useClickAway).mockImplementation((callback) => { - if (!invoked) { - invoked = true - callback(new Event('click')) - } - }) - render() - + fireEvent.click(screen.getByText('appLog.runDetail.workflowTitle')) expect(mockProps.onCancel).not.toHaveBeenCalled() }) - it('should not use click-away to close the floating dialog', () => { + it('keeps the floating dialog open when clicking its content', () => { fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) - - let clickAwayHandler!: (event: Event) => void - vi.mocked(useClickAway).mockImplementation((callback) => { - clickAwayHandler = callback - }) - render() - clickAwayHandler(new Event('click')) - + fireEvent.click(screen.getByRole('dialog')) expect(mockProps.onCancel).not.toHaveBeenCalled() }) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index 552a6c3f351267..5cce5164ed6af9 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -13,7 +13,6 @@ import { } from '@/service/share' import { TransferMethod } from '@/types/app' import { useChat } from '../../chat/hooks' -import { isValidGeneratedAnswer } from '../../utils' import ChatWrapper from '../chat-wrapper' import { useChatWithHistoryContext } from '../context' @@ -42,11 +41,6 @@ vi.mock('@/next/navigation', () => ({ useParams: vi.fn(() => ({ token: 'test-token' })), })) -vi.mock('../../utils', () => ({ - isValidGeneratedAnswer: vi.fn(), - getLastAnswer: vi.fn(), -})) - vi.mock('@/service/share', () => ({ fetchChatList: vi.fn(), fetchSuggestedQuestions: vi.fn(), @@ -67,10 +61,6 @@ vi.mock('@/app/components/base/markdown', () => ({ Markdown: ({ content }: { content: string }) =>
{content}
, })) -vi.mock('@/utils/model-config', () => ({ - formatBooleanInputs: vi.fn((forms, inputs) => inputs), -})) - vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: (timestamp: number) => `formatted-${timestamp}`, @@ -159,6 +149,36 @@ describe('ChatWrapper', () => { vi.mocked(useChat).mockReturnValue(defaultChatHookReturn as ChatHookReturn) }) + it.each([ + { id: 'a0', isAnswer: true, expected: 'a0' }, + { id: 'q0', isAnswer: false, expected: null }, + { id: 'answer-placeholder-0', isAnswer: true, expected: null }, + ])( + 'regenerates with the actual valid parent rule for $id', + async ({ id, isAnswer, expected }) => { + const handleSend = vi.fn() + vi.mocked(useChat).mockReturnValue({ + ...defaultChatHookReturn, + handleSend, + chatList: [ + { id, isAnswer, content: 'Parent message' }, + { id: 'q1', content: 'Question to regenerate', parentMessageId: id }, + { id: 'a1', isAnswer: true, content: 'Answer to regenerate', parentMessageId: 'q1' }, + ], + } as ChatHookReturn) + render() + fireEvent.click( + screen.getAllByRole('button', { name: 'common.operation.regenerate' }).at(-1)!, + ) + await waitFor(() => + expect(handleSend).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ query: 'Question to regenerate', parent_message_id: expected }), + expect.any(Object), + ), + ) + }, + ) it('should render welcome screen and handle message sending', async () => { const handleSend = vi.fn() vi.mocked(useChatWithHistoryContext).mockReturnValue({ @@ -262,71 +282,6 @@ describe('ChatWrapper', () => { expect(handleStop).toHaveBeenCalled() }) - it('should handle regenerate and switch sibling', async () => { - const handleSend = vi.fn() - const handleSwitchSibling = vi.fn() - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'q1', content: 'Q1' }, - { - id: 'a1', - isAnswer: true, - content: 'A1', - parentMessageId: 'q1', - siblingCount: 2, - siblingIndex: 0, - nextSibling: 'a2', - }, - ], - handleSend, - handleSwitchSibling, - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - expect(handleSend).toHaveBeenCalled() - } - - const switchText = await screen.findByText(/1\s*\/\s*2/) - const switchContainer = switchText.parentElement - const nextButton = switchContainer?.querySelectorAll('button')?.[1] - if (nextButton) { - fireEvent.click(nextButton) - expect(handleSwitchSibling).toHaveBeenCalledWith('a2', expect.any(Object)) - } - }) - - it('should handle regenerate with parent answer', async () => { - const handleSend = vi.fn() - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'a0', isAnswer: true, content: 'A0' }, - { id: 'q1', content: 'Q1', parentMessageId: 'a0' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - handleSend, - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - expect(handleSend).toHaveBeenCalled() - } - }) - it('should handle regenerate with edited question', async () => { const handleSend = vi.fn() vi.mocked(useChat).mockReturnValue({ @@ -869,66 +824,6 @@ describe('ChatWrapper', () => { } }) - it('should handle doRegenerate logic correctly', async () => { - const handleSend = vi.fn() - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - handleSend, - chatList: [ - { id: 'q1', content: 'Q1' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - // doRegenerate calls doSend with isRegenerate=true and parentAnswer=null (since q1 has no parent answer) - - expect(handleSend).toHaveBeenCalled() - const args = handleSend.mock.calls[0] - // args[1] is data - expect(args![1].query).toBe('Q1') - expect(args![1].parent_message_id).toBeNull() - } - }) - - it('should handle doRegenerate with valid parent answer', async () => { - const handleSend = vi.fn() - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - handleSend, - chatList: [ - { id: 'a0', isAnswer: true, content: 'A0' }, - { id: 'q1', content: 'Q1', parentMessageId: 'a0' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - } as unknown as ChatHookReturn) - - // Mock isValidGeneratedAnswer to return true - vi.mocked(isValidGeneratedAnswer).mockReturnValue(true) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - expect(handleSend).toHaveBeenCalled() - const args = handleSend.mock.calls[0] - expect(args![1].parent_message_id).toBe('a0') - } - }) - it('should handle human input form submission for installed app', async () => { const { submitHumanInputForm: submitWorkflowForm } = await import('@/service/workflow') vi.mocked(submitWorkflowForm).mockResolvedValue({}) @@ -1336,15 +1231,17 @@ describe('ChatWrapper', () => { expect(container)!.toBeInTheDocument() }) - it('should call formatBooleanInputs when sending message', async () => { - const { formatBooleanInputs } = await import('@/utils/model-config') + it('sends checkbox inputs as booleans while preserving text inputs', async () => { const handleSend = vi.fn() vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...defaultContextValue, currentConversationId: '', - inputsForms: [{ variable: 'test', type: 'text' }], - newConversationInputs: { test: 'value' }, + inputsForms: [ + { key: 'test', name: 'Text', type: 'string' }, + { key: 'agree', name: 'Agree', type: 'checkbox' }, + ], + newConversationInputs: { test: 'value', agree: 1 }, }) vi.mocked(useChat).mockReturnValue({ @@ -1359,7 +1256,11 @@ describe('ChatWrapper', () => { fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', keyCode: 13 }) await waitFor(() => { - expect(formatBooleanInputs).toHaveBeenCalled() + expect(handleSend).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ inputs: { test: 'value', agree: true }, query: 'Hello' }), + expect.any(Object), + ) }) }) @@ -1596,30 +1497,6 @@ describe('ChatWrapper', () => { }) }) - it('should handle doRegenerate when parentAnswer is not a valid generated answer', async () => { - const handleSend = vi.fn() - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'q1', content: 'Q1' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - handleSend, - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - // This tests line 198-200 when parentAnswer is not valid - expect(handleSend).toHaveBeenCalled() - } - }) - it('should handle doSwitchSibling with all parameters', () => { const handleSwitchSibling = vi.fn() @@ -1833,36 +1710,6 @@ describe('ChatWrapper', () => { expect(screen.getByText('Default opening statement'))!.toBeInTheDocument() }) - it('should handle doSend when regenerating with null parentAnswer', async () => { - const handleSend = vi.fn() - - vi.mocked(useChatWithHistoryContext).mockReturnValue({ - ...defaultContextValue, - currentConversationId: '', - }) - - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [{ id: 'q1', content: 'Question' }], - handleSend, - } as unknown as ChatHookReturn) - - render() - - // Simulate regenerate with no parent - this tests line 190 with null - const regenerateBtn = screen - .getByText('Question') - .closest('.chat-answer-container') - ?.querySelector('button .ri-reset-left-line')?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - } - - // The key is that when isRegenerate is true and parentAnswer is null, - // and there's no lastAnswer, it should use || null - expect(handleSend).toBeDefined() - }) - it('should handle doRegenerate with editedQuestion containing files', async () => { const handleSend = vi.fn() @@ -1906,46 +1753,6 @@ describe('ChatWrapper', () => { expect(screen.getByText('A1'))!.toBeInTheDocument() }) - it('should handle doRegenerate when question has message_files', async () => { - const handleSend = vi.fn() - - // Create proper FileEntity mock with all required fields - const mockFiles = [ - { - id: 'file1', - name: 'test.txt', - type: 'text/plain', - size: 1024, - url: 'https://example.com/test.txt', - extension: 'txt', - mime_type: 'text/plain', - } as unknown as FileEntity, - ] as FileEntity[] - - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'q1', content: 'Q1', message_files: mockFiles }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - handleSend, - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - // This tests line 200 - question.message_files branch - await waitFor(() => { - expect(handleSend).toHaveBeenCalled() - }) - } - }) - it('should test doSwitchSibling for new conversation', () => { const handleSwitchSibling = vi.fn() @@ -1988,101 +1795,6 @@ describe('ChatWrapper', () => { } }) - it('should handle parentAnswer that is not a valid generated answer', async () => { - const handleSend = vi.fn() - - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'a0', content: 'Not a valid answer' }, // Not marked as isAnswer - { id: 'q1', content: 'Q1', parentMessageId: 'a0' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - handleSend, - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - // This tests line 200 when isValidGeneratedAnswer returns false - await waitFor(() => { - expect(handleSend).toHaveBeenCalled() - }) - } - }) - - it('should use parent answer id when parentAnswer is valid', async () => { - const handleSend = vi.fn() - - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'a0', isAnswer: true, content: 'A0' }, // Valid answer - { id: 'q1', content: 'Q1', parentMessageId: 'a0' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - handleSend, - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - // This tests line 200 when isValidGeneratedAnswer returns true - await waitFor(() => { - expect(handleSend).toHaveBeenCalled() - }) - } - }) - - it('should handle regenerate when isRegenerate is true with parentAnswer.id', async () => { - const handleSend = vi.fn() - - vi.mocked(useChatWithHistoryContext).mockReturnValue({ - ...defaultContextValue, - currentConversationId: '', - }) - - vi.mocked(useChat).mockReturnValue({ - ...defaultChatHookReturn, - chatList: [ - { id: 'a0', isAnswer: true, content: 'A0' }, - { id: 'q1', content: 'Q1', parentMessageId: 'a0' }, - { id: 'a1', isAnswer: true, content: 'A1', parentMessageId: 'q1' }, - ], - handleSend, - } as unknown as ChatHookReturn) - - render() - - const answerContainer = screen.getByText('A1').closest('.chat-answer-container') - const regenerateBtn = answerContainer?.querySelector( - 'button .ri-reset-left-line', - )?.parentElement - if (regenerateBtn) { - fireEvent.click(regenerateBtn) - // This tests line 190 - the isRegenerate ? parentAnswer?.id branch - await waitFor(() => { - expect(handleSend).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - parent_message_id: 'a0', - }), - expect.any(Object), - ) - }) - } - }) - it('should ensure all branches of inputDisabled are covered', () => { // Test with non-required fields vi.mocked(useChatWithHistoryContext).mockReturnValue({ diff --git a/web/app/components/base/chat/chat/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/__tests__/index.spec.tsx index 28bdcd0863709e..a37421458bd2eb 100644 --- a/web/app/components/base/chat/chat/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/index.spec.tsx @@ -76,14 +76,6 @@ vi.mock('../chat-input-area', () => ({ ), })) -vi.mock('es-toolkit/compat', () => ({ - debounce: (fn: (...args: unknown[]) => void) => { - const debounced = (...args: unknown[]) => fn(...args) - debounced.cancel = vi.fn() - return debounced - }, -})) - // ─── ResizeObserver capture ─────────────────────────────────────────────────── type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void diff --git a/web/app/components/base/chat/chat/__tests__/question.spec.tsx b/web/app/components/base/chat/chat/__tests__/question.spec.tsx index 497f5bd1ed5a7b..61640a54b88196 100644 --- a/web/app/components/base/chat/chat/__tests__/question.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/question.spec.tsx @@ -9,9 +9,6 @@ import { ChatContextProvider } from '../context-provider' import Question from '../question' // Global Mocks -vi.mock('@react-aria/interactions', () => ({ - useFocusVisible: () => ({ isFocusVisible: false }), -})) vi.mock('../content-switch', () => ({ default: ({ count, diff --git a/web/app/components/base/chat/chat/__tests__/utils.spec.ts b/web/app/components/base/chat/chat/__tests__/utils.spec.ts index 41d24ddda1dbf8..28574ec28361d0 100644 --- a/web/app/components/base/chat/chat/__tests__/utils.spec.ts +++ b/web/app/components/base/chat/chat/__tests__/utils.spec.ts @@ -2,10 +2,6 @@ import type { InputForm } from '../type' import { InputVarType } from '@/app/components/workflow/types' import { getProcessedInputs, processInputFileFromServer, processOpeningStatement } from '../utils' -vi.mock('@/app/components/base/file-uploader/utils', () => ({ - getProcessedFiles: vi.fn((files: File[]) => files.map((f: File) => ({ ...f, processed: true }))), -})) - describe('chat/chat/utils.ts', () => { describe('processOpeningStatement', () => { it('returns empty string if openingStatement is falsy', () => { @@ -69,7 +65,13 @@ describe('chat/chat/utils.ts', () => { it('processes singleFile using transfer_method logic', () => { const inputs = { file1: { transfer_method: 'local_file', url: '1' }, - file2: { id: 'file2' }, + file2: { + id: 'file2', + uploadedId: 'upload-2', + progress: 100, + supportFileType: 'document', + transferMethod: 'local_file', + }, } const inputsForm = [ { variable: 'file1', type: InputVarType.singleFile as string }, @@ -77,13 +79,26 @@ describe('chat/chat/utils.ts', () => { ] const result = getProcessedInputs(inputs, inputsForm as InputForm[]) expect(result.file1).toHaveProperty('transfer_method', 'local_file') - expect(result.file2).toHaveProperty('processed', true) + expect(result.file2).toEqual({ + type: 'document', + transfer_method: 'local_file', + url: '', + upload_file_id: 'upload-2', + }) }) it('processes multiFiles using transfer_method logic', () => { const inputs = { files1: [{ transfer_method: 'local_file', url: '1' }], - files2: [{ id: 'file2' }], + files2: [ + { + id: 'file2', + uploadedId: 'upload-2', + progress: 100, + supportFileType: 'document', + transferMethod: 'local_file', + }, + ], } const inputsForm = [ { variable: 'files1', type: InputVarType.multiFiles as string }, @@ -91,7 +106,12 @@ describe('chat/chat/utils.ts', () => { ] const result = getProcessedInputs(inputs, inputsForm as InputForm[]) expect(result.files1[0]).toHaveProperty('transfer_method', 'local_file') - expect(result.files2[0]).toHaveProperty('processed', true) + expect(result.files2[0]).toEqual({ + type: 'document', + transfer_method: 'local_file', + url: '', + upload_file_id: 'upload-2', + }) }) it('processes jsonObject parsing correct json', () => { diff --git a/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx index 1450d234f91cdb..a1dee2ae4ff9c2 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx @@ -34,11 +34,6 @@ vi.mock('@/app/components/base/file-uploader', () => ({ ), })) -vi.mock('@/app/components/base/file-uploader/utils', () => ({ - getProcessedFilesFromResponse: (files: FileEntity[]) => - files.map((f) => ({ ...f, name: `processed-${f.id}` })), -})) - describe('AgentContent', () => { const mockItem: ChatItem = { id: '1', @@ -121,14 +116,30 @@ describe('AgentContent', () => { agent_thoughts: [ { thought: 'T1', - message_files: [{ id: 'file1' }, { id: 'file2' }], + message_files: [ + { + id: 'file1', + related_id: 'upload-1', + filename: 'report.pdf', + mime_type: 'application/pdf', + type: 'document', + transfer_method: 'local_file', + }, + { + id: 'file2', + related_id: 'upload-2', + filename: 'notes.txt', + mime_type: 'text/plain', + type: 'document', + transfer_method: 'local_file', + }, + ], }, ], } - render() - expect(screen.getByTestId('file-list-component')).toHaveTextContent( - 'processed-file1, processed-file2', - ) + // Historical thought responses carry server file metadata before AgentContent normalizes it. + render() + expect(screen.getByTestId('file-list-component')).toHaveTextContent('report.pdf, notes.txt') }) it('renders nothing if no annotation, content, or thoughts', () => { diff --git a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx index 70e9f7ad4907e5..6c760be601d2ef 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx @@ -4,11 +4,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import Answer from '../index' // Mock the chat context -vi.mock('../context', () => ({ - useChatContext: vi.fn(() => ({ - getHumanInputNodeData: vi.fn(), - })), -})) vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx index 7a32edae91894a..d36def444de392 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/expiration-time.spec.tsx @@ -1,40 +1,39 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vite-plus/test' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import ExpirationTime from '../expiration-time' -import * as utils from '../utils' - -// Mock utils to control time-based logic -vi.mock('../utils', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - getRelativeTime: vi.fn(), - isRelativeTimeSameOrAfter: vi.fn(), - } -}) + +const now = new Date('2026-01-15T12:00:00Z') describe('ExpirationTime', () => { - it('should render "Future" state with relative time', () => { - vi.mocked(utils.getRelativeTime).mockReturnValue('in 2 hours') - vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(true) + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.useRealTimers() + }) - const { container } = render() + it('shows the remaining time until the deadline and changes to expired after it', () => { + const deadline = now.getTime() + 2 * 60 * 60 * 1000 + const { rerender } = render() - expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-tertiary') expect( screen.getByText('share.humanInput.expirationTimeNowOrFuture:{"relativeTime":"in 2 hours"}'), ).toBeInTheDocument() - expect(container.querySelector('.i-ri-time-line')).toBeInTheDocument() - }) - - it('should render "Expired" state when time is in the past', () => { - vi.mocked(utils.getRelativeTime).mockReturnValue('2 hours ago') - vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(false) + expect(screen.queryByText('share.humanInput.expiredTip')).not.toBeInTheDocument() - const { container } = render() + vi.setSystemTime(deadline + 1) + rerender() - expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-warning') expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument() - expect(container.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + expect(screen.queryByText(/expirationTimeNowOrFuture/)).not.toBeInTheDocument() + }) + + it('treats the exact deadline as current rather than expired', () => { + render() + + expect(screen.getByText(/share.humanInput.expirationTimeNowOrFuture/)).toBeInTheDocument() + expect(screen.queryByText('share.humanInput.expiredTip')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx index 351442be5ed282..4b45407c1864f2 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx @@ -1,17 +1,11 @@ import type { Resources } from '../index' -import { render, screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { downloadUrl } from '@/utils/download' import Popup from '../popup' -const { mockUseDocumentDownload } = vi.hoisted(() => ({ mockUseDocumentDownload: vi.fn() })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.byDocumentId.download.get': () => mockUseDocumentDownload(), - }) -}) - vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), })) @@ -75,10 +69,7 @@ const queryDownloadButton = (name = 'report.pdf') => describe('Popup', () => { beforeEach(() => { vi.clearAllMocks() - mockUseDocumentDownload.mockReturnValue({ - mutateAsync: mockDownloadDocument, - isPending: false, - }) + mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' }) }) describe('Rendering – Trigger', () => { @@ -208,17 +199,19 @@ describe('Popup', () => { expect(queryDownloadButton()).not.toBeInTheDocument() }) - it('should disable the download button while isDownloading is true', async () => { - mockUseDocumentDownload.mockReturnValue({ - mutateAsync: mockDownloadDocument, - isPending: true, - }) + it('disables download while pending and re-enables it after completion', async () => { + const response = createDeferred<{ url: string }>() + mockDownloadDocument.mockReturnValue(response.promise) const user = userEvent.setup() render() - await openPopup(user) - - expect(getDownloadButton()).toBeDisabled() + await user.click(getDownloadButton()) + await waitFor(() => expect(getDownloadButton()).toBeDisabled()) + await user.click(getDownloadButton()) + expect(mockDownloadDocument).toHaveBeenCalledTimes(1) + await act(async () => response.resolve({ url: 'https://example.com/file.pdf' })) + await waitFor(() => expect(getDownloadButton()).toBeEnabled()) + expect(mockDownloadUrl).toHaveBeenCalledTimes(1) }) }) @@ -544,20 +537,6 @@ describe('Popup', () => { expect(mockDownloadDocument).not.toHaveBeenCalled() }) - it('should not call downloadDocument when isDownloading is true', async () => { - mockUseDocumentDownload.mockReturnValue({ - mutateAsync: mockDownloadDocument, - isPending: true, - }) - const user = userEvent.setup() - render() - - await openPopup(user) - await user.click(getDownloadButton()) - - expect(mockDownloadDocument).not.toHaveBeenCalled() - }) - it('should use documentId from data.documentId as priority over sources[0].document_id', async () => { mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' }) const user = userEvent.setup() @@ -827,3 +806,15 @@ describe('Popup', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.byDocumentId.download.get': (input) => + mockDownloadDocument(input), + }), + } +}) diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index a66373148b05af..381100df0a3916 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -5,11 +5,15 @@ import { renderWithConsoleQuery } from '@/test/console/query-data' import { FeaturesProvider } from '../../context' import NewFeaturePanel from '../index' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'codeBasedExtension.get': () => ({ data: undefined }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'codeBasedExtension.get': async () => undefined, + }), + } }) vi.mock('@/next/navigation', () => ({ @@ -32,39 +36,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ - ConfigurationMethodEnum: { - predefinedModel: 'predefined-model', - customizableModel: 'customizable-model', - fetchFromRemote: 'fetch-from-remote', - }, - ModelFeatureEnum: { - toolCall: 'tool-call', - multiToolCall: 'multi-tool-call', - agentThought: 'agent-thought', - streamToolCall: 'stream-tool-call', - vision: 'vision', - video: 'video', - document: 'document', - audio: 'audio', - polling: 'polling', - StructuredOutput: 'structured-output', - }, - ModelStatusEnum: { - active: 'active', - noConfigure: 'no-configure', - quotaExceeded: 'quota-exceeded', - noPermission: 'no-permission', - disabled: 'disabled', - credentialRemoved: 'credential-removed', - }, - ModelTypeEnum: { - speech2text: 'speech2text', - tts: 'tts', - textEmbedding: 'text-embedding', - }, -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ ModelSelector: () =>
Model Selector
, })) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx index 46a6706b7bd1f0..d7c764285b2640 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/config-param-modal.spec.tsx @@ -17,12 +17,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn, })) -vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ - ModelTypeEnum: { - textEmbedding: 'text-embedding', - }, -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ ModelSelector: ({ value, diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx index 9e24bba8ea4457..c1deea40cb7b18 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx @@ -72,12 +72,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ - ModelTypeEnum: { - textEmbedding: 'text-embedding', - }, -})) - vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ ModelSelector: () =>
Model Selector
, })) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx index 1a4a7e8245f11b..2f8d601f14e963 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx @@ -11,11 +11,6 @@ const getPromptEditor = () => { return editor as HTMLElement } -vi.mock('@/utils/var', () => ({ - checkKeys: (_keys: string[]) => ({ isValid: true }), - getNewVar: (key: string, type: string) => ({ key, name: key, type, required: true }), -})) - vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () => ({ default: ({ varNameArr, diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx index 198acd3b06d080..2fa35b8669b723 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx @@ -1,13 +1,18 @@ import type { Features, OnFeaturesChange } from '../../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { FeaturesProvider } from '../../../context' import FileUpload from '../index' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'files.upload.get': () => ({ data: undefined }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'files.upload.get': async () => undefined, + }), + } }) const defaultFeatures: Features = { diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx index 24ce4e80538b16..179eecb94ca40f 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-content.spec.tsx @@ -42,12 +42,6 @@ vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', ), })) -vi.mock('@/app/components/workflow/types', () => ({ - SupportUploadFileTypes: { - image: 'image', - }, -})) - const defaultFeatures: Features = { moreLikeThis: { enabled: false }, opening: { enabled: false }, diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx index 0cdbab950cc6a5..6d734f27ff36af 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx @@ -1,14 +1,19 @@ import type { Features } from '../../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { TransferMethod } from '@/types/app' import { FeaturesProvider } from '../../../context' import FileUploadSettings from '../setting-modal' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'files.upload.get': () => ({ data: undefined }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'files.upload.get': async () => undefined, + }), + } }) const defaultFeatures: Features = { diff --git a/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx index 810581eeff163f..cf40b5291ee84f 100644 --- a/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/image-upload/__tests__/index.spec.tsx @@ -1,13 +1,18 @@ import type { Features, OnFeaturesChange } from '../../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { FeaturesProvider } from '../../../context' import ImageUpload from '../index' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'files.upload.get': () => ({ data: undefined }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'files.upload.get': async () => undefined, + }), + } }) const defaultFeatures: Features = { diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx index 043080402b4824..485a29ca03c651 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { Features, OnFeaturesChange } from '../../../types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { FeaturesProvider } from '../../../context' import Moderation from '../index' @@ -38,11 +39,15 @@ const renderWithProvider = ( ) } -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'codeBasedExtension.get': () => ({ data: { data: mockCodeBasedExtensionData } }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'codeBasedExtension.get': async () => ({ data: mockCodeBasedExtensionData }), + }), + } }) describe('Moderation', () => { @@ -408,7 +413,7 @@ describe('Moderation', () => { expect(screen.getByText('-'))!.toBeInTheDocument() }) - it('should show code-based extension label when custom type is configured', () => { + it('should show code-based extension label when custom type is configured', async () => { mockCodeBasedExtensionData.push({ name: 'custom-ext', label: { 'en-US': 'Custom Moderation', 'zh-Hans': '自定义审核' }, @@ -428,7 +433,7 @@ describe('Moderation', () => { }, ) - expect(screen.getByText('Custom Moderation'))!.toBeInTheDocument() + expect(await screen.findByText('Custom Moderation'))!.toBeInTheDocument() }) it('should not show enable content text when both input and output moderation are disabled', () => { diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx index c9d4d4dbebedad..590271f4577d44 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal-submission.spec.tsx @@ -3,7 +3,8 @@ import type { ModerationConfig } from '@/models/debug' import type { ConsoleClient } from '@/service/console' import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { renderWithConsoleQuery as renderWithConsoleQueryWithoutUrlState } from '@/test/console/query-data' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import ModerationSettingModal from '../moderation-setting-modal' vi.mock('@tanstack/react-query', async (importOriginal) => { @@ -37,11 +38,6 @@ vi.mock('@/service/console', async (importOriginal) => { return { ...actual, consoleClient, consoleQuery: createConsoleQuery(consoleClient) } }) -vi.mock('nuqs', async (importOriginal) => ({ - ...(await importOriginal()), - useQueryState: () => [null, vi.fn()], -})) - const existingExtension: ApiBasedExtensionResponse = { id: 'existing-extension', name: 'Existing API', @@ -66,6 +62,12 @@ const moderation: ModerationConfig = { const childDialogName = 'common.apiBasedExtension.modal.title' const outputLabel = 'appDebug.feature.moderation.modal.content.output' +const onUrlUpdate = vi.fn() +const renderWithConsoleQuery = createNuqsTestRenderer( + renderWithConsoleQueryWithoutUrlState, + () => ({ onUrlUpdate }), +) + const renderModal = async () => { const onSave = vi.fn() const onCancel = vi.fn() diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx index e6d93de33e6fe1..6f4b2027049fb3 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/moderation-setting-modal.spec.tsx @@ -1,16 +1,17 @@ import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types.gen' import type { TFunction } from 'i18next' import type { ModerationConfig } from '@/models/debug' -import { act, fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as i18n from 'react-i18next' import { buildConfigurationDatasetConfigs } from '@/app/components/app/configuration/hooks/configuration-lifecycle/dataset' import { createPublishHandler } from '@/app/components/app/configuration/hooks/configuration-lifecycle/publish' import { buildPublishedConfig } from '@/app/components/app/configuration/hooks/configuration-lifecycle/published-config' import { createFeaturesStore } from '@/app/components/base/features/store' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { renderWithConsoleQuery as renderWithoutUrlState } from '@/test/console/query-data' import { createAppModelConfigFixture } from '@/test/fixtures/app' import { withSelectorKey } from '@/test/i18n-mock' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { ModelModeType } from '@/types/app' import ModerationSettingModal from '../moderation-setting-modal' @@ -21,12 +22,6 @@ vi.mock('@/app/notifications', () => ({ }, })) -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - let mockCodeBasedExtensions: { data: { data: Record[] } } = { data: { data: [] } } let mockModelProvidersData: { data: { data: Record[] } @@ -50,16 +45,6 @@ let mockModelProvidersData: { refetch: vi.fn(), } -vi.mock( - '@/app/components/header/account-setting/model-provider-page/declarations', - async (importOriginal) => ({ - ...(await importOriginal< - typeof import('@/app/components/header/account-setting/model-provider-page/declarations') - >()), - CustomConfigurationStatusEnum: { active: 'active' }, - }), -) - vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({ ApiBasedExtensionSelector: ({ onChange }: { value: string; onChange: (v: string) => void }) => (
@@ -88,6 +73,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('ModerationSettingModal', () => { const onSave = vi.fn() const renderModal = async (ui: React.ReactElement) => { @@ -680,7 +668,9 @@ describe('ModerationSettingModal', () => { fireEvent.click(screen.getByText(/settings\.provider/)) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'), + ) }) it('should not save when OpenAI type is selected but not configured', async () => { diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index 9d8a8e019e7155..0581fc5ecd3004 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -1,9 +1,13 @@ import type { Features } from '../../../types' import type { OnFeaturesChange } from '@/app/components/base/features/types' -import { skipToken } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import i18next from 'i18next' +import { consoleQuery } from '@/service/console' +import { + createConsoleQueryClient, + renderWithConsoleQuery as render, +} from '@/test/console/query-data' import { TtsAutoPlay } from '@/types/app' import { FeaturesProvider } from '../../../context' import ParamConfigContent from '../param-config-content' @@ -20,19 +24,20 @@ let mockVoiceItems: { value: string; name: string }[] | undefined = [ { value: 'echo', name: 'Echo' }, ] -type VoicesQueryOptions = { - enabled?: boolean - input: - | typeof skipToken - | { - params: { app_id: string } | { agent_id: string } - query: { language: string } - } -} - -const mockVoicesQuery = vi.fn((_options: VoicesQueryOptions) => ({ - data: mockVoiceItems, -})) +let seedVoices = true +const getAppVoices = vi.fn() +const getAgentVoices = vi.fn() +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.textToAudio.voices.get': (input) => getAppVoices(input), + 'agent.byAgentId.textToAudio.voices.get': (input) => getAgentVoices(input), + }), + } +}) const mockGetAudioPlayer = vi.fn(() => ({ playAudio: vi.fn(), pauseAudio: vi.fn() })) @@ -42,11 +47,6 @@ vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ }, })) -vi.mock('@tanstack/react-query', async (importOriginal) => ({ - ...(await importOriginal()), - useQuery: (options: VoicesQueryOptions) => mockVoicesQuery(options), -})) - vi.mock('@/next/navigation', () => ({ usePathname: () => mockParams.appId @@ -80,10 +80,29 @@ const renderWithProvider = ( featureOverrides?: Partial, ) => { const features = { ...defaultFeatures, ...featureOverrides } + const queryClient = createConsoleQueryClient() + if (seedVoices && mockVoiceItems) { + const language = features.text2speech?.language || 'en-US' + if (mockParams.agentId) + queryClient.setQueryData( + consoleQuery.agent.byAgentId.textToAudio.voices.get.queryKey({ + input: { params: { agent_id: mockParams.agentId }, query: { language } }, + }), + mockVoiceItems, + ) + else if (mockParams.appId) + queryClient.setQueryData( + consoleQuery.apps.byAppId.textToAudio.voices.get.queryKey({ + input: { params: { app_id: mockParams.appId }, query: { language } }, + }), + mockVoiceItems, + ) + } return render( , + { queryClient }, ) } @@ -98,6 +117,9 @@ describe('ParamConfigContent', () => { beforeEach(() => { vi.clearAllMocks() + seedVoices = true + getAppVoices.mockImplementation(() => Promise.resolve(mockVoiceItems ?? [])) + getAgentVoices.mockImplementation(() => Promise.resolve(mockVoiceItems ?? [])) mockParams = { appId: 'test-app-id' } mockLanguages = [ { value: 'en-US', name: 'English', example: 'Hello world' }, @@ -233,30 +255,28 @@ describe('ParamConfigContent', () => { route: 'Chatflow', params: { appId: 'test-app-id' }, requestParams: { app_id: 'test-app-id' }, - operation: ['console', 'apps', 'byAppId', 'textToAudio', 'voices', 'get'], + request: getAppVoices, }, { route: 'Agent', params: { agentId: 'test-agent-id' }, requestParams: { agent_id: 'test-agent-id' }, - operation: ['console', 'agent', 'byAgentId', 'textToAudio', 'voices', 'get'], + request: getAgentVoices, }, ])( 'should load and select voices using the $route route ID', - async ({ params, requestParams, operation }) => { + async ({ params, requestParams, request }) => { const user = userEvent.setup() const onChange = vi.fn() mockParams = params + seedVoices = false renderWithProvider({ onChange }) - expect(mockVoicesQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: expect.arrayContaining([operation]), - input: { - params: requestParams, - query: { language: 'en-US' }, - }, + await waitFor(() => + expect(request).toHaveBeenCalledWith({ + params: requestParams, + query: { language: 'en-US' }, }), ) @@ -269,13 +289,10 @@ describe('ParamConfigContent', () => { await user.click(getLanguageSelect()) await user.click(await screen.findByRole('option', { name: /voice\.language\.zhHans/ })) - expect(mockVoicesQuery).toHaveBeenLastCalledWith( - expect.objectContaining({ - queryKey: expect.arrayContaining([operation]), - input: { - params: requestParams, - query: { language: 'zh-Hans' }, - }, + await waitFor(() => + expect(request).toHaveBeenCalledWith({ + params: requestParams, + query: { language: 'zh-Hans' }, }), ) }, @@ -440,12 +457,8 @@ describe('ParamConfigContent', () => { renderWithProvider() - expect(mockVoicesQuery).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: false, - input: skipToken, - }), - ) + expect(getAppVoices).not.toHaveBeenCalled() + expect(getAgentVoices).not.toHaveBeenCalled() }) it('should render language text when selected language value is empty string', () => { diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index 495692c9a46f79..d38f28a69454aa 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -1,71 +1,104 @@ import type { UploadConfig } from '@dify/contracts/api/console/files/types.gen' +import type { ReactNode } from 'react' import type { FileEntity } from '../types' import type { FileUpload } from '@/app/components/base/features/types' -import { act, renderHook } from '@testing-library/react' +import { ORPCError } from '@orpc/client' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { toast } from '@/app/notifications' +import { uploadRemoteFileInfo } from '@/service/common' +import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } from '@/service/share' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' import { getFileSizeLimit, useFile } from '../hooks' +import { createFileStore, FileContext, useStore } from '../store' +import { FileUploadContext } from '../upload-context' +import { fileUpload } from '../utils' -const mockNotify = vi.fn() -const mockNavigationState = vi.hoisted(() => ({ - params: {} as { token?: string }, - pathname: '/chat', -})) -const mockFileUploadContext = vi.hoisted(() => ({ - localUploadUrl: undefined as string | undefined, - remoteUploadUrl: undefined as string | undefined, -})) - +const navigation = vi.hoisted(() => ({ params: {} as { token?: string }, pathname: '/chat' })) vi.mock('@/next/navigation', () => ({ - useParams: () => mockNavigationState.params, - usePathname: () => mockNavigationState.pathname, -})) - -vi.mock('../upload-context', () => ({ - useFileUploadContext: () => mockFileUploadContext, -})) - -vi.mock('@/app/notifications', () => ({ - toast: { - error: (message: string) => mockNotify({ type: 'error', message }), - }, -})) - -const mockSetFiles = vi.fn() -let mockStoreFiles: FileEntity[] = [] -vi.mock('../store', () => ({ - useFileStore: () => ({ - getState: () => ({ - files: mockStoreFiles, - setFiles: mockSetFiles, - }), - }), + useParams: () => navigation.params, + usePathname: () => navigation.pathname, })) - -const mockFileUpload = vi.fn() -const mockIsAllowedFileExtension = vi.fn().mockReturnValue(true) -const mockGetSupportFileType = vi.fn().mockReturnValue('document') -vi.mock('../utils', () => ({ - fileUpload: (...args: unknown[]) => mockFileUpload(...args), - getFileUploadErrorMessage: vi.fn().mockReturnValue('Upload error'), - getSupportFileType: (...args: unknown[]) => mockGetSupportFileType(...args), - isAllowedFileExtension: (...args: unknown[]) => mockIsAllowedFileExtension(...args), -})) - -const mockUploadRemoteFileInfo = vi.fn() -vi.mock('@/service/common', () => ({ - uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args), +vi.mock('../utils', async (importOriginal) => ({ + ...(await importOriginal()), + fileUpload: vi.fn(), })) - -const mockUploadHumanInputFormLocalFile = vi.fn() -const mockUploadHumanInputFormRemoteFileInfo = vi.fn() +vi.mock('@/service/common', () => ({ uploadRemoteFileInfo: vi.fn() })) vi.mock('@/service/share', () => ({ - uploadHumanInputFormLocalFile: (...args: unknown[]) => mockUploadHumanInputFormLocalFile(...args), - uploadHumanInputFormRemoteFileInfo: (...args: unknown[]) => - mockUploadHumanInputFormRemoteFileInfo(...args), + uploadHumanInputFormLocalFile: vi.fn(), + uploadHumanInputFormRemoteFileInfo: vi.fn(), })) -vi.mock('uuid', () => ({ - v4: () => 'mock-uuid', -})) +const config: FileUpload = { + enabled: true, + allowed_file_types: ['image', 'document', 'audio', 'video'], + allowed_file_extensions: [], + allowed_file_upload_methods: ['local_file', 'remote_url'], + number_limits: 5, +} +const uploadResponse = { + id: 'uploaded-file', + name: 'report.pdf', + mime_type: 'application/pdf', + size: 100, + created_at: 0, + created_by: 'user-1', + extension: 'pdf', + preview_url: null, + source_url: '', + url: 'https://example.com/report.pdf', +} +const entity = (id: string): FileEntity => ({ + id, + name: `${id}.txt`, + type: 'text/plain', + size: 1, + progress: 0, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile: new File(['text'], `${id}.txt`, { type: 'text/plain' }), +}) +function setup({ + files = [], + fileConfig = config, + uploadUrls = {}, + checkEnabled = false, +}: { + files?: FileEntity[] + fileConfig?: FileUpload + uploadUrls?: { localUploadUrl?: string; remoteUploadUrl?: string } + checkEnabled?: boolean +} = {}) { + const onChange = vi.fn() + const store = createFileStore(files, onChange) + const wrapper = ({ children }: { children: ReactNode }) => + createElement( + FileContext, + { value: store }, + createElement(FileUploadContext, { value: uploadUrls }, children), + ) + return { + ...renderHook( + () => ({ ...useFile(fileConfig, !checkEnabled), files: useStore((state) => state.files) }), + { wrapper }, + ), + store, + onChange, + } +} + +beforeEach(() => { + vi.clearAllMocks() + navigation.params = {} + navigation.pathname = '/chat' + vi.spyOn(toast, 'error').mockReturnValue('toast-id') + vi.mocked(uploadRemoteFileInfo).mockResolvedValue(uploadResponse) + vi.mocked(uploadHumanInputFormRemoteFileInfo).mockResolvedValue(uploadResponse) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) describe('getFileSizeLimit', () => { beforeEach(() => { @@ -124,894 +157,198 @@ describe('getFileSizeLimit', () => { }) }) -describe('useFile', () => { - const defaultFileConfig: FileUpload = { - enabled: true, - allowed_file_types: ['image', 'document'], - allowed_file_extensions: [], - number_limits: 5, - } as FileUpload - - beforeEach(() => { - vi.clearAllMocks() - mockStoreFiles = [] - mockNavigationState.params = {} - mockNavigationState.pathname = '/chat' - mockFileUploadContext.localUploadUrl = undefined - mockFileUploadContext.remoteUploadUrl = undefined - mockIsAllowedFileExtension.mockReturnValue(true) - mockGetSupportFileType.mockReturnValue('document') - }) - - it('should return all file handler functions', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) - - expect(result.current.handleAddFile).toBeDefined() - expect(result.current.handleUpdateFile).toBeDefined() - expect(result.current.handleRemoveFile).toBeDefined() - expect(result.current.handleReUploadFile).toBeDefined() - expect(result.current.handleLoadFileFromLink).toBeDefined() - expect(result.current.handleLoadFileFromLinkSuccess).toBeDefined() - expect(result.current.handleLoadFileFromLinkError).toBeDefined() - expect(result.current.handleClearFiles).toBeDefined() - expect(result.current.handleLocalFileUpload).toBeDefined() - expect(result.current.handleClipboardPasteFile).toBeDefined() - expect(result.current.handleDragFileEnter).toBeDefined() - expect(result.current.handleDragFileOver).toBeDefined() - expect(result.current.handleDragFileLeave).toBeDefined() - expect(result.current.handleDropFile).toBeDefined() - expect(result.current.isDragActive).toBe(false) - }) - - it('should add a file via handleAddFile', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) - - result.current.handleAddFile({ - id: 'test-id', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: 0, - transferMethod: 'local_file', - supportFileType: 'document', - } as FileEntity) - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should update a file via handleUpdateFile', () => { - mockStoreFiles = [{ id: 'file-1', name: 'a.txt', progress: 0 }] as FileEntity[] - const { result } = renderHook(() => useFile(defaultFileConfig)) - - result.current.handleUpdateFile({ id: 'file-1', name: 'a.txt', progress: 50 } as FileEntity) - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should not update file when id is not found', () => { - mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[] - const { result } = renderHook(() => useFile(defaultFileConfig)) - - result.current.handleUpdateFile({ id: 'nonexistent' } as FileEntity) - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should remove a file via handleRemoveFile', () => { - mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[] - const { result } = renderHook(() => useFile(defaultFileConfig)) - - result.current.handleRemoveFile('file-1') - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should clear all files via handleClearFiles', () => { - mockStoreFiles = [{ id: 'a' }] as FileEntity[] - const { result } = renderHook(() => useFile(defaultFileConfig)) - - result.current.handleClearFiles() - expect(mockSetFiles).toHaveBeenCalledWith([]) - }) - - describe('handleReUploadFile', () => { - it('should re-upload a file and call fileUpload', () => { - const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [ - { - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - - result.current.handleReUploadFile('file-1') - expect(mockSetFiles).toHaveBeenCalled() - expect(mockFileUpload).toHaveBeenCalled() - }) - - it('should use human input form local upload when re-uploading on form page', () => { - mockNavigationState.params = { token: 'form-token' } - mockNavigationState.pathname = '/form/form-token' - const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [ - { - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleReUploadFile('file-1') - - expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith( - expect.objectContaining({ - formToken: 'form-token', - file: originalFile, - }), - ) - expect(mockFileUpload).not.toHaveBeenCalled() - }) - - it('should not re-upload when file id is not found', () => { - mockStoreFiles = [] - const { result } = renderHook(() => useFile(defaultFileConfig)) - - result.current.handleReUploadFile('nonexistent') - expect(mockFileUpload).not.toHaveBeenCalled() - }) - - it('should handle progress callback during re-upload', () => { - const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [ - { - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleReUploadFile('file-1') - - const uploadCall = mockFileUpload.mock.calls[0]![0] - uploadCall.onProgressCallback(50) - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should handle success callback during re-upload', () => { - const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [ - { - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleReUploadFile('file-1') - - const uploadCall = mockFileUpload.mock.calls[0]![0] - uploadCall.onSuccessCallback({ id: 'uploaded-1' }) - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should handle error callback during re-upload', () => { - const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [ - { - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleReUploadFile('file-1') +it('writes consecutive file edits to the real store and notifies subscribers', () => { + const { result, onChange } = setup() + act(() => result.current.handleAddFile(entity('one'))) + act(() => result.current.handleAddFile(entity('two'))) + act(() => result.current.handleUpdateFile({ ...entity('one'), progress: 50 })) + expect(result.current.files.map(({ id, progress }) => ({ id, progress }))).toEqual([ + { id: 'one', progress: 50 }, + { id: 'two', progress: 0 }, + ]) + act(() => result.current.handleUpdateFile(entity('missing'))) + expect(result.current.files).toHaveLength(2) + act(() => result.current.handleRemoveFile('one')) + expect(result.current.files.map((file) => file.id)).toEqual(['two']) + act(() => result.current.handleClearFiles()) + expect(result.current.files).toEqual([]) + expect(onChange).toHaveBeenLastCalledWith([]) +}) - const uploadCall = mockFileUpload.mock.calls[0]![0] - uploadCall.onErrorCallback(new Error('fail')) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) +it('keeps concurrent local uploads separate while progress, failure and retry update the store', async () => { + const { result } = setup() + act(() => { + result.current.handleLocalFileUpload(new File(['a'], 'one.txt', { type: 'text/plain' })) + result.current.handleLocalFileUpload(new File(['b'], 'two.txt', { type: 'text/plain' })) }) - - describe('handleLoadFileFromLink', () => { - it('should run startProgressTimer to increment file progress', () => { - vi.useFakeTimers() - mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // never resolves - - // Set up a file in the store that has progress 0 - mockStoreFiles = [ - { - id: 'mock-uuid', - name: 'https://example.com/file.txt', - type: '', - size: 0, - progress: 0, - transferMethod: 'remote_url', - supportFileType: '', - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLoadFileFromLink('https://example.com/file.txt') - - // Advance timer to trigger the interval - vi.advanceTimersByTime(200) - expect(mockSetFiles).toHaveBeenCalled() - - vi.useRealTimers() - }) - - it('should add file and call uploadRemoteFileInfo', () => { - mockUploadRemoteFileInfo.mockResolvedValue({ - id: 'remote-1', - mime_type: 'text/plain', - size: 100, - name: 'remote.txt', - url: 'https://example.com/remote.txt', - }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLoadFileFromLink('https://example.com/file.txt') - - expect(mockSetFiles).toHaveBeenCalled() - expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false) - }) - - it('should upload a remote file through the configured resource-scoped endpoint', () => { - mockFileUploadContext.remoteUploadUrl = '/trial-apps/app-id/remote-files/upload' - mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLoadFileFromLink('https://example.com/file.txt') - - expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith( - 'https://example.com/file.txt', - false, - undefined, - '/trial-apps/app-id/remote-files/upload', - ) - }) - - it('should use human input form remote upload on form page', () => { - mockNavigationState.params = { token: 'form-token' } - mockNavigationState.pathname = '/form/form-token' - mockUploadHumanInputFormRemoteFileInfo.mockResolvedValue({ - id: 'remote-1', - mime_type: 'text/plain', - size: 100, - name: 'remote.txt', - url: 'https://example.com/remote.txt', - }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLoadFileFromLink('https://example.com/file.txt') - - expect(mockUploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith( - 'form-token', - 'https://example.com/file.txt', - ) - expect(mockUploadRemoteFileInfo).not.toHaveBeenCalled() - }) - - it('should remove file when extension is not allowed', async () => { - mockIsAllowedFileExtension.mockReturnValue(false) - mockUploadRemoteFileInfo.mockResolvedValue({ - id: 'remote-1', - mime_type: 'text/plain', - size: 100, - name: 'remote.txt', - url: 'https://example.com/remote.txt', - }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - await act(async () => { - result.current.handleLoadFileFromLink('https://example.com/file.txt') - await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) - }) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', async () => { - mockIsAllowedFileExtension.mockReturnValue(false) - mockUploadRemoteFileInfo.mockResolvedValue({ - id: 'remote-1', - mime_type: 'text/plain', - size: 100, - name: 'remote.txt', - url: 'https://example.com/remote.txt', - }) - - const configWithUndefined = { - ...defaultFileConfig, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - } as unknown as FileUpload - - const { result } = renderHook(() => useFile(configWithUndefined)) - await act(async () => { - result.current.handleLoadFileFromLink('https://example.com/file.txt') - await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) - }) - - expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('remote.txt', 'text/plain', [], []) - }) - - it('should remove file when remote upload fails', async () => { - mockUploadRemoteFileInfo.mockRejectedValue(new Error('network error')) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - await act(async () => { - result.current.handleLoadFileFromLink('https://example.com/file.txt') - await vi.waitFor(() => expect(mockNotify).toHaveBeenCalled()) - }) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should remove file when size limit is exceeded on remote upload', async () => { - mockGetSupportFileType.mockReturnValue('image') - mockUploadRemoteFileInfo.mockResolvedValue({ - id: 'remote-1', - mime_type: 'image/png', - size: 20 * 1024 * 1024, - name: 'large.png', - url: 'https://example.com/large.png', - }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - await act(async () => { - result.current.handleLoadFileFromLink('https://example.com/large.png') - await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) - }) - - // File should be removed because image exceeds 10MB limit - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should update file on successful remote upload within limits', async () => { - mockUploadRemoteFileInfo.mockResolvedValue({ - id: 'remote-1', - mime_type: 'text/plain', - size: 100, - name: 'remote.txt', - url: 'https://example.com/remote.txt', - }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - await act(async () => { - result.current.handleLoadFileFromLink('https://example.com/remote.txt') - await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) - }) - - // setFiles should be called: once for add, once for update - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should stop progress timer when file reaches 80 percent', () => { - vi.useFakeTimers() - mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) - - // Set up a file already at 80% progress - mockStoreFiles = [ - { - id: 'mock-uuid', - name: 'https://example.com/file.txt', - type: '', - size: 0, - progress: 80, - transferMethod: 'remote_url', - supportFileType: '', - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLoadFileFromLink('https://example.com/file.txt') - - // At progress 80, the timer should stop (clearTimeout path) - vi.advanceTimersByTime(200) - - vi.useRealTimers() - }) - - it('should stop progress timer when progress is negative', () => { - vi.useFakeTimers() - mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) - - // Set up a file with negative progress (error state) - mockStoreFiles = [ - { - id: 'mock-uuid', - name: 'https://example.com/file.txt', - type: '', - size: 0, - progress: -1, - transferMethod: 'remote_url', - supportFileType: '', - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLoadFileFromLink('https://example.com/file.txt') - - vi.advanceTimersByTime(200) - - vi.useRealTimers() - }) + await waitFor(() => expect(fileUpload).toHaveBeenCalledTimes(2)) + const first = vi.mocked(fileUpload).mock.calls[0]![0] + const second = vi.mocked(fileUpload).mock.calls[1]![0] + const firstId = result.current.files.find((file) => file.name === first.file.name)!.id + act(() => { + first.onProgressCallback(40) + second.onSuccessCallback({ ...uploadResponse, id: 'second' }) }) + expect(result.current.files.find((file) => file.id === firstId)?.progress).toBe(40) + expect(result.current.files.find((file) => file.name === second.file.name)?.uploadedId).toBe( + 'second', + ) + act(() => first.onErrorCallback(new ORPCError('file_extension_blocked'))) + expect(result.current.files.find((file) => file.id === firstId)?.progress).toBe(-1) + expect(toast.error).toHaveBeenCalledWith('common.fileUploader.fileExtensionBlocked') + act(() => result.current.handleReUploadFile(firstId)) + expect(result.current.files.find((file) => file.id === firstId)?.progress).toBe(0) + act(() => vi.mocked(fileUpload).mock.calls[2]![0].onSuccessCallback(uploadResponse)) + expect(result.current.files).toHaveLength(2) + expect(result.current.files.every((file) => file.progress === 100)).toBe(true) +}) - describe('handleLocalFileUpload', () => { - let capturedListeners: Record void)[]> - let mockReaderResult: string | null - - beforeEach(() => { - capturedListeners = {} - mockReaderResult = 'data:text/plain;base64,Y29udGVudA==' - - class MockFileReader { - result: string | null = null - addEventListener(event: string, handler: () => void) { - if (!capturedListeners[event]) capturedListeners[event] = [] - capturedListeners[event].push(handler) - } - - readAsDataURL() { - this.result = mockReaderResult - capturedListeners.load?.forEach((handler) => handler()) - } - } - vi.stubGlobal('FileReader', MockFileReader) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should upload a local file', () => { - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should reject file with unsupported extension', () => { - mockIsAllowedFileExtension.mockReturnValue(false) - const file = new File(['content'], 'test.xyz', { type: 'application/xyz' }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - expect(mockSetFiles).not.toHaveBeenCalled() - }) - - it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', () => { - mockIsAllowedFileExtension.mockReturnValue(false) - const file = new File(['content'], 'test.xyz', { type: 'application/xyz' }) - - const configWithUndefined = { - ...defaultFileConfig, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - } as unknown as FileUpload - - const { result } = renderHook(() => useFile(configWithUndefined)) - result.current.handleLocalFileUpload(file) - - expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('test.xyz', 'application/xyz', [], []) - }) - - it('should reject file when upload is disabled and noNeedToCheckEnable is false', () => { - const disabledConfig = { ...defaultFileConfig, enabled: false } as FileUpload - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - - const { result } = renderHook(() => useFile(disabledConfig, false)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should reject image file exceeding size limit', () => { - mockGetSupportFileType.mockReturnValue('image') - const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { - type: 'image/png', - }) - Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(largeFile) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should reject audio file exceeding size limit', () => { - mockGetSupportFileType.mockReturnValue('audio') - const largeFile = new File([], 'large.mp3', { type: 'audio/mpeg' }) - Object.defineProperty(largeFile, 'size', { value: 60 * 1024 * 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(largeFile) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should reject video file exceeding size limit', () => { - mockGetSupportFileType.mockReturnValue('video') - const largeFile = new File([], 'large.mp4', { type: 'video/mp4' }) - Object.defineProperty(largeFile, 'size', { value: 200 * 1024 * 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(largeFile) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should reject document file exceeding size limit', () => { - mockGetSupportFileType.mockReturnValue('document') - const largeFile = new File([], 'large.pdf', { type: 'application/pdf' }) - Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(largeFile) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should reject custom file exceeding document size limit', () => { - mockGetSupportFileType.mockReturnValue('custom') - const largeFile = new File([], 'large.xyz', { type: 'application/octet-stream' }) - Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(largeFile) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should allow custom file within document size limit', () => { - mockGetSupportFileType.mockReturnValue('custom') - const file = new File(['content'], 'file.xyz', { type: 'application/octet-stream' }) - Object.defineProperty(file, 'size', { value: 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).not.toHaveBeenCalled() - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should allow document file within size limit', () => { - mockGetSupportFileType.mockReturnValue('document') - const file = new File(['content'], 'small.pdf', { type: 'application/pdf' }) - Object.defineProperty(file, 'size', { value: 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).not.toHaveBeenCalled() - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should allow file with unknown type (default case)', () => { - mockGetSupportFileType.mockReturnValue('unknown') - const file = new File(['content'], 'test.bin', { type: 'application/octet-stream' }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - // Should not be rejected - unknown type passes checkSizeLimit - expect(mockNotify).not.toHaveBeenCalled() - }) - - it('should allow image file within size limit', () => { - mockGetSupportFileType.mockReturnValue('image') - const file = new File(['content'], 'small.png', { type: 'image/png' }) - Object.defineProperty(file, 'size', { value: 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).not.toHaveBeenCalled() - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should allow audio file within size limit', () => { - mockGetSupportFileType.mockReturnValue('audio') - const file = new File(['content'], 'small.mp3', { type: 'audio/mpeg' }) - Object.defineProperty(file, 'size', { value: 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).not.toHaveBeenCalled() - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should allow video file within size limit', () => { - mockGetSupportFileType.mockReturnValue('video') - const file = new File(['content'], 'small.mp4', { type: 'video/mp4' }) - Object.defineProperty(file, 'size', { value: 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).not.toHaveBeenCalled() - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should set base64Url for image files during upload', () => { - mockGetSupportFileType.mockReturnValue('image') - const file = new File(['content'], 'photo.png', { type: 'image/png' }) - Object.defineProperty(file, 'size', { value: 1024 }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockSetFiles).toHaveBeenCalled() - // The file should have been added with base64Url set (for image type) - const addedFiles = mockSetFiles.mock.calls[0]![0] - expect(addedFiles[0].base64Url).toBe('data:text/plain;base64,Y29udGVudA==') - }) - - it('should set empty base64Url for non-image files during upload', () => { - mockGetSupportFileType.mockReturnValue('document') - const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockSetFiles).toHaveBeenCalled() - const addedFiles = mockSetFiles.mock.calls[0]![0] - expect(addedFiles[0].base64Url).toBe('') - }) - - it('should call fileUpload with callbacks after FileReader loads', () => { - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockFileUpload).toHaveBeenCalled() - const uploadCall = mockFileUpload.mock.calls[0]![0] - - // Test progress callback - uploadCall.onProgressCallback(50) - expect(mockSetFiles).toHaveBeenCalled() - - // Test success callback - uploadCall.onSuccessCallback({ id: 'uploaded-1' }) - expect(mockSetFiles).toHaveBeenCalled() - }) - - it('should upload through the configured resource-scoped endpoint', () => { - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockFileUploadContext.localUploadUrl = '/trial-apps/app-id/files/upload' - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockFileUpload).toHaveBeenCalledWith( - expect.any(Object), - false, - '/trial-apps/app-id/files/upload', - ) - }) - - it('should use human input form local upload on form page', () => { - mockNavigationState.params = { token: 'form-token' } - mockNavigationState.pathname = '/form/form-token' - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith( - expect.objectContaining({ - formToken: 'form-token', - file, - }), - ) - expect(mockFileUpload).not.toHaveBeenCalled() - }) - - it('should handle fileUpload error callback', () => { - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - const uploadCall = mockFileUpload.mock.calls[0]![0] - uploadCall.onErrorCallback(new Error('upload failed')) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('should handle FileReader error event', () => { - capturedListeners = {} - const errorListeners: (() => void)[] = [] - - class ErrorFileReader { - result: string | null = null - addEventListener(event: string, handler: () => void) { - if (event === 'error') errorListeners.push(handler) - if (!capturedListeners[event]) capturedListeners[event] = [] - capturedListeners[event].push(handler) - } - - readAsDataURL() { - // Simulate error instead of load - errorListeners.forEach((handler) => handler()) - } - } - vi.stubGlobal('FileReader', ErrorFileReader) - - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) +it.each([ + ['photo.png', 'image/png', 'image', 'image_file_size_limit'], + ['report.pdf', 'application/pdf', 'document', 'file_size_limit'], + ['voice.mp3', 'audio/mpeg', 'audio', 'audio_file_size_limit'], + ['movie.mp4', 'video/mp4', 'video', 'video_file_size_limit'], +] as const)( + 'applies the actual %s type and its configured size limit', + async (name, type, expectedType, sizeKey) => { + const { result } = setup({ + fileConfig: { ...config, fileUploadConfig: createFileUploadConfigFixture({ [sizeKey]: 1 }) }, + }) + const oversized = new File(['a'], name, { type }) + Object.defineProperty(oversized, 'size', { value: 2 * 1024 * 1024 }) + act(() => result.current.handleLocalFileUpload(oversized)) + expect(fileUpload).not.toHaveBeenCalled() + expect(result.current.files).toEqual([]) + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('common.fileUploader.uploadFromComputerLimit'), + ) + act(() => result.current.handleLocalFileUpload(new File(['a'], name, { type }))) + await waitFor(() => expect(fileUpload).toHaveBeenCalledTimes(1)) + expect(result.current.files[0]).toEqual( + expect.objectContaining({ name, supportFileType: expectedType, progress: 0 }), + ) + expect(result.current.files[0]?.base64Url).toBe( + expectedType === 'image' ? 'data:image/png;base64,YQ==' : '', + ) + }, +) + +it('rejects unsupported extensions and accepts explicitly allowed custom files', async () => { + const file = new File(['data'], 'payload.xyz', { type: '' }) + const rejected = setup() + act(() => rejected.result.current.handleLocalFileUpload(file)) + expect(rejected.result.current.files).toEqual([]) + expect(fileUpload).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('fileExtensionNotSupport')) + const accepted = setup({ + fileConfig: { ...config, allowed_file_types: ['custom'], allowed_file_extensions: ['.xyz'] }, }) + act(() => accepted.result.current.handleLocalFileUpload(file)) + await waitFor(() => expect(fileUpload).toHaveBeenCalledTimes(1)) + expect(accepted.result.current.files[0]?.supportFileType).toBe('custom') +}) - describe('handleClipboardPasteFile', () => { - it('should handle file paste from clipboard', () => { - const file = new File(['content'], 'pasted.png', { type: 'image/png' }) - const { result } = renderHook(() => useFile(defaultFileConfig)) - - const event = { - clipboardData: { - files: [file], - getData: () => '', - }, - preventDefault: vi.fn(), - } as unknown as React.ClipboardEvent - - result.current.handleClipboardPasteFile(event) - expect(event.preventDefault).toHaveBeenCalled() - }) - - it('should not handle paste when text is present', () => { - const file = new File(['content'], 'pasted.png', { type: 'image/png' }) - const { result } = renderHook(() => useFile(defaultFileConfig)) - - const event = { - clipboardData: { - files: [file], - getData: () => 'some text', - }, - preventDefault: vi.fn(), - } as unknown as React.ClipboardEvent +it('respects disabled uploads when the caller enables the check', () => { + const { result } = setup({ fileConfig: { ...config, enabled: false }, checkEnabled: true }) + act(() => + result.current.handleLocalFileUpload(new File(['data'], 'a.txt', { type: 'text/plain' })), + ) + expect(fileUpload).not.toHaveBeenCalled() + expect(result.current.files).toEqual([]) + expect(toast.error).toHaveBeenCalledWith('common.fileUploader.uploadDisabled') +}) - result.current.handleClipboardPasteFile(event) - expect(event.preventDefault).not.toHaveBeenCalled() - }) +it('uses resource-specific local and remote upload endpoints', async () => { + const { result } = setup({ + uploadUrls: { localUploadUrl: '/resources/files', remoteUploadUrl: '/resources/remote-files' }, }) + act(() => + result.current.handleLocalFileUpload(new File(['data'], 'a.txt', { type: 'text/plain' })), + ) + await waitFor(() => + expect(fileUpload).toHaveBeenCalledWith(expect.any(Object), false, '/resources/files'), + ) + await act(async () => result.current.handleLoadFileFromLink('https://example.com/report.pdf')) + expect(uploadRemoteFileInfo).toHaveBeenCalledWith( + 'https://example.com/report.pdf', + false, + undefined, + '/resources/remote-files', + ) + expect(result.current.files.find((file) => file.isRemote)?.uploadedId).toBe('uploaded-file') +}) - describe('drag and drop handlers', () => { - it('should set isDragActive on drag enter', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) - - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - } as unknown as React.DragEvent - act(() => { - result.current.handleDragFileEnter(event) - }) - - expect(result.current.isDragActive).toBe(true) - }) - - it('should call preventDefault on drag over', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) - - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - } as unknown as React.DragEvent - result.current.handleDragFileOver(event) - - expect(event.preventDefault).toHaveBeenCalled() - }) - - it('should unset isDragActive on drag leave', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) - - const enterEvent = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - } as unknown as React.DragEvent - act(() => { - result.current.handleDragFileEnter(enterEvent) - }) - expect(result.current.isDragActive).toBe(true) - - const leaveEvent = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - } as unknown as React.DragEvent - act(() => { - result.current.handleDragFileLeave(leaveEvent) - }) - expect(result.current.isDragActive).toBe(false) - }) - - it('should handle file drop', () => { - const file = new File(['content'], 'dropped.txt', { type: 'text/plain' }) - const { result } = renderHook(() => useFile(defaultFileConfig)) - - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { files: [file] }, - } as unknown as React.DragEvent - - act(() => { - result.current.handleDropFile(event) - }) - - expect(event.preventDefault).toHaveBeenCalled() - expect(result.current.isDragActive).toBe(false) - }) - - it('should not upload when no file is dropped', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) +it('routes human-input form uploads and retries through the form token', async () => { + navigation.params = { token: 'form-token' } + navigation.pathname = '/form/form-token' + const { result } = setup({ files: [entity('retry')] }) + act(() => result.current.handleReUploadFile('retry')) + expect(uploadHumanInputFormLocalFile).toHaveBeenCalledWith( + expect.objectContaining({ formToken: 'form-token', file: expect.any(File) }), + ) + act(() => + result.current.handleLocalFileUpload(new File(['data'], 'a.txt', { type: 'text/plain' })), + ) + await waitFor(() => expect(uploadHumanInputFormLocalFile).toHaveBeenCalledTimes(2)) + await act(async () => result.current.handleLoadFileFromLink('https://example.com/report.pdf')) + expect(uploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith( + 'form-token', + 'https://example.com/report.pdf', + ) + expect(fileUpload).not.toHaveBeenCalled() + expect(uploadRemoteFileInfo).not.toHaveBeenCalled() +}) - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { files: [] }, - } as unknown as React.DragEvent +it('keeps public chat tokens on the normal upload transport', async () => { + navigation.params = { token: 'chat-token' } + navigation.pathname = '/chat/chat-token' + const { result } = setup() + act(() => + result.current.handleLocalFileUpload(new File(['data'], 'a.txt', { type: 'text/plain' })), + ) + await waitFor(() => expect(fileUpload).toHaveBeenCalledWith(expect.any(Object), true, undefined)) + await act(async () => result.current.handleLoadFileFromLink('https://example.com/report.pdf')) + expect(uploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/report.pdf', true) + expect(uploadHumanInputFormLocalFile).not.toHaveBeenCalled() +}) - act(() => { - result.current.handleDropFile(event) +it.each(['unsupported', 'oversized', 'failed'] as const)( + 'removes a remote file when it is %s', + async (condition) => { + if (condition === 'failed') + vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(new Error('offline')) + else + vi.mocked(uploadRemoteFileInfo).mockResolvedValueOnce({ + ...uploadResponse, + ...(condition === 'unsupported' + ? { name: 'file.xyz', mime_type: 'application/octet-stream' } + : { size: 100 * 1024 * 1024 }), }) - - // No file upload should be triggered - expect(mockSetFiles).not.toHaveBeenCalled() - }) - }) - - describe('noop handlers', () => { - it('should have handleLoadFileFromLinkSuccess as noop', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) - - expect(() => result.current.handleLoadFileFromLinkSuccess()).not.toThrow() - }) - - it('should have handleLoadFileFromLinkError as noop', () => { - const { result } = renderHook(() => useFile(defaultFileConfig)) - - expect(() => result.current.handleLoadFileFromLinkError()).not.toThrow() - }) - }) + const { result } = setup() + await act(async () => result.current.handleLoadFileFromLink('https://example.com/file')) + expect(result.current.files).toEqual([]) + expect(toast.error).toHaveBeenCalled() + }, +) + +it('advances pending remote progress only to 80 percent and completes from the response', async () => { + vi.useFakeTimers() + let resolve!: (value: typeof uploadResponse) => void + vi.mocked(uploadRemoteFileInfo).mockReturnValueOnce( + new Promise((done) => { + resolve = done + }), + ) + const { result } = setup() + act(() => result.current.handleLoadFileFromLink('https://example.com/report.pdf')) + act(() => vi.advanceTimersByTime(2000)) + expect(result.current.files[0]?.progress).toBe(80) + await act(async () => resolve(uploadResponse)) + expect(result.current.files[0]).toEqual( + expect.objectContaining({ + progress: 100, + uploadedId: 'uploaded-file', + supportFileType: 'document', + }), + ) }) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx index 76286c36adb8dd..66d5d432568b81 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/file-item.spec.tsx @@ -8,10 +8,6 @@ vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), })) -vi.mock('@/utils/format', () => ({ - formatFileSize: (size: number) => `${size}B`, -})) - const createFile = (overrides: Partial = {}): FileEntity => ({ id: 'file-1', name: 'document.pdf', @@ -40,7 +36,7 @@ describe('FileInAttachmentItem', () => { it('should render file size', () => { render() - expect(screen.getByText(/2048B/))!.toBeInTheDocument() + expect(screen.getByText(/2.00 KB/))!.toBeInTheDocument() }) it('should render FileTypeIcon for non-image files', () => { diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx index fdd0038c782ae0..7da34ef3db9e1e 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/__tests__/index.spec.tsx @@ -13,10 +13,6 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('@/utils/format', () => ({ - formatFileSize: (size: number) => `${size}B`, -})) - vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), })) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx index d34c455ea0b43a..df2b6f1685ebb1 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-item.spec.tsx @@ -8,10 +8,6 @@ vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), })) -vi.mock('@/utils/format', () => ({ - formatFileSize: (size: number) => `${size}B`, -})) - vi.mock('../../pdf-highlighter-adapter', () => ({ PdfLoader: ({ url, children }: { url: string; children: (doc: unknown) => ReactNode }) => (
@@ -50,7 +46,7 @@ describe('FileItem (chat-input)', () => { // Extension and size are rendered as text nodes in the metadata div expect(container.textContent).toContain('pdf') - expect(container.textContent).toContain('2048B') + expect(container.textContent).toContain('2.00 KB') }) it('should render FileTypeIcon', () => { diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx index 64298bdbe09e83..960bc03bea7ef3 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-list.spec.tsx @@ -12,10 +12,6 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('@/utils/format', () => ({ - formatFileSize: (size: number) => `${size}B`, -})) - vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), })) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx index a3712284533ff5..3d2f5acabc8f01 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/index.spec.tsx @@ -3,17 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { FileContextProvider } from '../../store' import FileUploaderInChatInput from '../index' -vi.mock('@/types/app', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - TransferMethod: { - local_file: 'local_file', - remote_url: 'remote_url', - }, - } -}) - vi.mock('../../hooks', () => ({ useFile: () => ({ handleLoadFileFromLink: vi.fn(), diff --git a/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx b/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx index a2a31818d3ea19..aa2ecb44ff8658 100644 --- a/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx +++ b/web/app/components/base/form/components/base/__tests__/base-form.spec.tsx @@ -1,29 +1,10 @@ -import type { AnyFieldApi, AnyFormApi } from '@tanstack/react-form' import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' -import { useStore } from '@tanstack/react-form' +import { useForm, useStore } from '@tanstack/react-form' import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types' import BaseForm from '../base-form' -vi.mock('@tanstack/react-form', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useStore: vi.fn((store, selector) => { - // If a selector is provided, apply it to a mocked state or the store directly - if (selector) { - // If the store is a mock with state, use it; otherwise provide a default - try { - return selector(store?.state || { values: {} }) - } catch { - return {} - } - } - return store?.state?.values || {} - }), - } -}) - vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => ({ data: undefined, @@ -129,33 +110,24 @@ describe('BaseForm', () => { expect(screen.getByText('Title warning'))!.toBeInTheDocument() }) - it('should use formFromProps if provided', () => { - const mockState = { values: { kind: 'show' } } - const mockStore = { - state: mockState, + it('uses the supplied form for editing and conditional field visibility', async () => { + const user = userEvent.setup() + const ExternalForm = () => { + const form = useForm({ defaultValues: { kind: 'show', title: 'External title' } }) + const kind = useStore(form.store, (state) => state.values.kind) + return ( + <> + + {kind} + + ) } - vi.mocked(useStore).mockReturnValueOnce(mockState.values) - const mockForm = { - store: mockStore, - Field: ({ - children, - name, - }: { - children: (field: AnyFieldApi) => React.ReactNode - name: string - }) => - children({ - name, - state: { - value: mockState.values[name as keyof typeof mockState.values], - meta: { isTouched: false, errorMap: {} }, - }, - form: { store: mockStore }, - } as unknown as AnyFieldApi), - setFieldValue: vi.fn(), - } - render() - expect(screen.getByText('Kind'))!.toBeInTheDocument() + render() + expect(screen.getByDisplayValue('External title')).toBeInTheDocument() + await user.clear(screen.getByDisplayValue('show')) + await user.type(screen.getAllByRole('textbox')[0]!, 'hide') + expect(screen.getByRole('status')).toHaveTextContent('hide') + expect(screen.queryByDisplayValue('External title')).not.toBeInTheDocument() }) it('should handle setFields with explicit validateStatus', async () => { @@ -203,26 +175,6 @@ describe('BaseForm', () => { expect(onSubmit).toHaveBeenCalled() }) - it('should render nothing if field name does not match schema in renderField', () => { - const mockState = { values: { unknown: 'value' } } - const mockStore = { - state: mockState, - } - vi.mocked(useStore).mockReturnValueOnce(mockState.values) - const mockForm = { - store: mockStore, - Field: ({ children }: { children: (field: AnyFieldApi) => React.ReactNode }) => - children({ - name: 'unknown', // field name not in baseSchemas - state: { value: 'value', meta: { isTouched: false, errorMap: {} } }, - form: { store: mockStore }, - } as unknown as AnyFieldApi), - setFieldValue: vi.fn(), - } - render() - expect(screen.queryByText('Kind')).not.toBeInTheDocument() - }) - it('should handle undefined formSchemas', () => { const { container } = render() expect(container)!.toBeEmptyDOMElement() diff --git a/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx b/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx deleted file mode 100644 index d5e5a6d25c72dc..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import CheckboxField from '../checkbox' - -const mockField = { - name: 'checkbox-field', - state: { - value: false, - }, - handleChange: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -describe('CheckboxField', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should toggle on when unchecked users click the checkbox', () => { - mockField.state.value = false - render() - fireEvent.click(screen.getByRole('checkbox', { name: 'Enable feature' })) - expect(mockField.handleChange).toHaveBeenCalledWith(true) - }) - - it('should toggle off when checked users click the label', () => { - mockField.state.value = true - render() - fireEvent.click(screen.getByText('Enable feature')) - expect(mockField.handleChange).toHaveBeenCalledWith(false) - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/field-integration.spec.tsx b/web/app/components/base/form/components/field/__tests__/field-integration.spec.tsx new file mode 100644 index 00000000000000..d57bd099879066 --- /dev/null +++ b/web/app/components/base/form/components/field/__tests__/field-integration.spec.tsx @@ -0,0 +1,199 @@ +import type { ReactElement } from 'react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useAppForm } from '@/app/components/base/form' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' +import { TransferMethod } from '@/types/app' +import CheckboxField from '../checkbox' +import FileTypesField from '../file-types' +import FileUploaderField from '../file-uploader' +import InputTypeSelectField from '../input-type-select' +import NumberInputField from '../number-input' +import NumberSliderField from '../number-slider' +import OptionsField from '../options' +import SelectField from '../select' +import TextAreaField from '../text-area' + +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ token: 'test-token' }), + usePathname: () => '/', +})) + +const renderField = (field: ReactElement, initialValue: unknown) => { + const Harness = () => { + const form = useAppForm({ defaultValues: { value: initialValue } }) + return ( + <> + {() => field} + state.values}> + {(values) => {JSON.stringify(values)}} + + + ) + } + const queryClient = createConsoleQueryClient() + queryClient.setQueryData( + consoleQuery.files.upload.get.queryKey(), + createFileUploadConfigFixture(), + ) + return renderWithConsoleQuery(, { queryClient }) +} +const readValue = () => + JSON.parse(screen.getByRole('status', { name: 'Form values' }).textContent!).value + +it('writes checkbox changes to the form and supports toggling twice', async () => { + const user = userEvent.setup() + renderField(, false) + const checkbox = screen.getByRole('checkbox', { name: 'Enabled' }) + await user.click(checkbox) + expect(checkbox).toBeChecked() + expect(readValue()).toBe(true) + await user.click(checkbox) + expect(readValue()).toBe(false) +}) + +it('writes textarea edits into the form', async () => { + const user = userEvent.setup() + renderField(, 'Initial') + const input = screen.getByRole('textbox', { name: 'Note' }) + await user.clear(input) + await user.type(input, 'Updated') + expect(readValue()).toBe('Updated') + expect(input).toHaveValue('Updated') +}) + +it('writes numeric values, clamps edits and resets cleared values to zero', async () => { + const user = userEvent.setup() + renderField(, 2) + await user.click(screen.getByRole('button', { name: 'Increment value' })) + expect(readValue()).toBe(3) + const input = screen.getByRole('textbox', { name: 'Count' }) + fireEvent.change(input, { target: { value: '12' } }) + expect(readValue()).toBe(10) + await user.click(input) + await user.tab() + await waitFor(() => expect(input).toHaveValue('10')) + await user.clear(input) + expect(readValue()).toBe(0) +}) + +it('writes slider changes and clamps numeric input on blur', async () => { + const user = userEvent.setup() + renderField(, 2) + const slider = screen.getByRole('slider', { name: 'Threshold' }) + slider.focus() + await user.keyboard('{ArrowRight}') + expect(readValue()).toBe(3) + const input = screen.getByRole('spinbutton', { name: 'Threshold' }) + await user.clear(input) + await user.type(input, '12') + await user.tab() + expect(readValue()).toBe(10) +}) + +it('keeps the empty selection label and writes the chosen option to the form', async () => { + const user = userEvent.setup() + renderField( + , + '', + ) + const select = screen.getByRole('combobox', { name: 'Mode' }) + expect(select).toHaveTextContent('None') + await user.click(select) + await user.click(screen.getByRole('option', { name: 'Beta' })) + expect(select).toHaveTextContent('Beta') + expect(readValue()).toBe('beta') +}) + +it('adds, edits and removes option strings in the form', async () => { + const user = userEvent.setup() + renderField(, ['Existing']) + await user.click(screen.getByRole('button', { name: 'appDebug.variableConfig.addOption' })) + await user.type( + screen.getByRole('textbox', { name: 'appDebug.variableConfig.options 2' }), + 'Added', + ) + expect(readValue()).toEqual(['Existing', 'Added']) + await user.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[0]!) + expect(readValue()).toEqual(['Added']) +}) + +it('switches between custom and standard file types without losing custom extensions', async () => { + const user = userEvent.setup() + renderField(, { + allowedFileTypes: ['document'], + allowedFileExtensions: [], + }) + const customName = 'appDebug.variableConfig.file.custom.name' + await user.click(screen.getByRole('checkbox', { name: customName })) + expect(readValue()).toEqual({ allowedFileTypes: ['custom'], allowedFileExtensions: [] }) + await user.type(screen.getByRole('textbox', { name: customName }), '.csv{Enter}') + expect(readValue()).toEqual({ allowedFileTypes: ['custom'], allowedFileExtensions: ['.csv'] }) + await user.click( + screen.getByRole('checkbox', { name: 'appDebug.variableConfig.file.image.name' }), + ) + expect(readValue()).toEqual({ allowedFileTypes: ['image'], allowedFileExtensions: ['.csv'] }) + await user.click( + screen.getByRole('checkbox', { name: 'appDebug.variableConfig.file.image.name' }), + ) + expect(readValue().allowedFileTypes).toEqual([]) +}) + +it.each([true, false])( + 'writes input type choices and honors file support=%s', + async (supportFile) => { + const user = userEvent.setup() + renderField(, 'text-input') + await user.click(screen.getByRole('combobox', { name: 'Input type' })) + if (supportFile) + expect( + screen.getByRole('option', { name: /appDebug.variableConfig.single-file/ }), + ).toBeInTheDocument() + else + expect( + screen.queryByRole('option', { name: /appDebug.variableConfig.single-file/ }), + ).not.toBeInTheDocument() + await user.click(screen.getByRole('option', { name: /appDebug.variableConfig.number/ })) + expect(readValue()).toBe('number') + }, +) + +it('removes an uploaded file from both the field and the form state', async () => { + const user = userEvent.setup() + renderField( + , + [ + { + id: 'file-1', + name: 'report.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.document, + uploadedId: 'uploaded-1', + url: 'https://example.com/report.pdf', + }, + ], + ) + expect(screen.getByText('report.pdf')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /remove|delete/i })) + expect(screen.queryByText('report.pdf')).not.toBeInTheDocument() + expect(readValue()).toEqual([]) +}) diff --git a/web/app/components/base/form/components/field/__tests__/file-types.spec.tsx b/web/app/components/base/form/components/field/__tests__/file-types.spec.tsx deleted file mode 100644 index 5af4275239c985..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/file-types.spec.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { SupportUploadFileTypes } from '@/app/components/workflow/types' -import FileTypesField from '../file-types' - -type FileTypeValue = { - allowedFileTypes: string[] - allowedFileExtensions: string[] -} - -const mockField = { - name: 'allowed-types', - state: { - value: { - allowedFileTypes: [], - allowedFileExtensions: [], - } as FileTypeValue, - }, - handleChange: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/file-type-item', () => ({ - default: ({ - type, - onToggle, - customFileTypes = [], - onCustomFileTypesChange, - }: { - type: SupportUploadFileTypes - onToggle: (type: SupportUploadFileTypes) => void - customFileTypes?: string[] - onCustomFileTypesChange?: (types: string[]) => void - }) => ( -
- - {onCustomFileTypesChange && ( - - onCustomFileTypesChange( - e.target.value - .split(',') - .map((v) => v.trim()) - .filter(Boolean), - ) - } - /> - )} -
- ), -})) - -describe('FileTypesField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = { - allowedFileTypes: [], - allowedFileExtensions: [], - } - }) - - it('should render the label and available type options', () => { - render() - - expect(screen.getByText('Allowed file types')).toBeInTheDocument() - expect( - screen.getByRole('button', { name: SupportUploadFileTypes.document }), - ).toBeInTheDocument() - expect(screen.getByRole('button', { name: SupportUploadFileTypes.image })).toBeInTheDocument() - expect(screen.getByRole('button', { name: SupportUploadFileTypes.audio })).toBeInTheDocument() - expect(screen.getByRole('button', { name: SupportUploadFileTypes.video })).toBeInTheDocument() - expect(screen.getByRole('button', { name: SupportUploadFileTypes.custom })).toBeInTheDocument() - }) - - it('should keep only custom when users choose custom types', () => { - mockField.state.value.allowedFileTypes = [SupportUploadFileTypes.document] - render() - - fireEvent.click(screen.getByRole('button', { name: SupportUploadFileTypes.custom })) - expect(mockField.handleChange).toHaveBeenCalledWith({ - allowedFileTypes: [SupportUploadFileTypes.custom], - allowedFileExtensions: [], - }) - }) - - it('should remove custom and add selected standard type', () => { - mockField.state.value.allowedFileTypes = [SupportUploadFileTypes.custom] - render() - - fireEvent.click(screen.getByRole('button', { name: SupportUploadFileTypes.image })) - expect(mockField.handleChange).toHaveBeenCalledWith({ - allowedFileTypes: [SupportUploadFileTypes.image], - allowedFileExtensions: [], - }) - }) - - it('should remove custom when users click custom again', () => { - mockField.state.value.allowedFileTypes = [SupportUploadFileTypes.custom] - render() - - fireEvent.click(screen.getByRole('button', { name: SupportUploadFileTypes.custom })) - expect(mockField.handleChange).toHaveBeenCalledWith({ - allowedFileTypes: [], - allowedFileExtensions: [], - }) - }) - - it('should remove a selected standard type when users click it again', () => { - mockField.state.value.allowedFileTypes = [SupportUploadFileTypes.image] - render() - - fireEvent.click(screen.getByRole('button', { name: SupportUploadFileTypes.image })) - expect(mockField.handleChange).toHaveBeenCalledWith({ - allowedFileTypes: [], - allowedFileExtensions: [], - }) - }) - - it('should update custom extensions when users type custom extension values', () => { - render() - - fireEvent.change(screen.getByRole('textbox', { name: 'custom file extensions' }), { - target: { value: 'csv,pdf' }, - }) - expect(mockField.handleChange).toHaveBeenCalledWith({ - allowedFileTypes: [], - allowedFileExtensions: ['csv', 'pdf'], - }) - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx b/web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx deleted file mode 100644 index 3f20afa738fb36..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { SupportUploadFileTypes } from '@/app/components/workflow/types' -import { TransferMethod } from '@/types/app' -import FileUploaderField from '../file-uploader' - -const mockField = { - name: 'files', - state: { - value: [ - { - id: 'file-1', - name: 'report.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: TransferMethod.local_file, - supportFileType: SupportUploadFileTypes.document, - uploadedId: 'uploaded-1', - url: 'https://example.com/report.pdf', - }, - ], - }, - handleChange: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -vi.mock('@/next/navigation', () => ({ - useParams: () => ({ token: 'test-token' }), - usePathname: () => '/', -})) - -describe('FileUploaderField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = [ - { - id: 'file-1', - name: 'report.pdf', - size: 1024, - type: 'application/pdf', - progress: 100, - transferMethod: TransferMethod.local_file, - supportFileType: SupportUploadFileTypes.document, - uploadedId: 'uploaded-1', - url: 'https://example.com/report.pdf', - }, - ] - }) - - it('should render existing uploaded file name', () => { - render( - , - ) - - expect(screen.getByText('Attachments'))!.toBeInTheDocument() - expect(screen.getByText('report.pdf'))!.toBeInTheDocument() - }) - - it('should update field value when users remove a file', () => { - render( - , - ) - - const deleteButtons = screen.getAllByRole('button') - fireEvent.click(deleteButtons[1]!) - expect(mockField.handleChange).toHaveBeenCalledWith([]) - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx deleted file mode 100644 index d2f3eb82cf9955..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import NumberInputField from '../number-input' - -const mockField = { - name: 'number-field', - state: { - value: 2, - }, - handleChange: vi.fn(), - handleBlur: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -describe('NumberInputField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = 2 - }) - - it('should render current number value', () => { - render() - expect(screen.getByLabelText('Count')).toHaveValue('2') - }) - - it('should update value when users click increment', () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'Increment value' })) - expect(mockField.handleChange).toHaveBeenCalledWith(3) - }) - - it('should reset field value when users clear the input', () => { - render() - fireEvent.change(screen.getByLabelText('Count'), { target: { value: '' } }) - expect(mockField.handleChange).toHaveBeenCalledWith(0) - }) - - it('should clamp out-of-range edits before updating field state', () => { - render() - - fireEvent.change(screen.getByLabelText('Count'), { target: { value: '12' } }) - - expect(mockField.handleChange).toHaveBeenLastCalledWith(10) - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx deleted file mode 100644 index 1f0dff7d99c492..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/number-slider.spec.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import NumberSliderField from '../number-slider' - -const mockField = { - name: 'slider-field', - state: { - value: 2, - }, - handleChange: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/input-number-with-slider', () => ({ - default: ({ - label, - value, - onChange, - }: { - label: string - value: number - onChange: (value: number) => void - }) => , -})) - -describe('NumberSliderField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = 2 - }) - - it('should render description when provided', () => { - render() - expect(screen.getByText('Used to control threshold')).toBeInTheDocument() - }) - - it('should update value when users interact with slider', () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'Threshold-slider-value-2' })) - expect(mockField.handleChange).toHaveBeenCalledWith(3) - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/options.spec.tsx b/web/app/components/base/form/components/field/__tests__/options.spec.tsx deleted file mode 100644 index b51e93b406c031..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/options.spec.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import OptionsField from '../options' - -const mockField = { - name: 'options-field', - state: { - value: [] as { label: string; value: string }[], - }, - handleChange: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({ - default: ({ onChange }: { onChange: (value: { label: string; value: string }[]) => void }) => ( - - ), -})) - -describe('OptionsField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = [] - }) - - it('should render label and options control', () => { - render() - expect(screen.getByText('Allowed options')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'apply-options' })).toBeInTheDocument() - }) - - it('should update options when users apply changes', () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'apply-options' })) - expect(mockField.handleChange).toHaveBeenCalledWith([{ label: 'A', value: 'a' }]) - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/select.spec.tsx b/web/app/components/base/form/components/field/__tests__/select.spec.tsx deleted file mode 100644 index 45cc87d1574f38..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/select.spec.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import SelectField from '../select' - -const mockField = { - name: 'select-field', - state: { - value: 'alpha', - }, - handleChange: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -describe('SelectField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = 'alpha' - }) - - it('should render selected value', () => { - render( - , - ) - expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('Alpha') - }) - - it('should render the option label when selected value is an empty string', () => { - mockField.state.value = '' - - render( - , - ) - - expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('No default selected') - }) - - it('should update value when users select another option', async () => { - const user = userEvent.setup() - render( - , - ) - await user.click(screen.getByRole('combobox', { name: 'Mode' })) - await user.click(screen.getByRole('option', { name: 'Beta' })) - expect(mockField.handleChange).toHaveBeenCalledWith('beta') - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/text-area.spec.tsx b/web/app/components/base/form/components/field/__tests__/text-area.spec.tsx deleted file mode 100644 index ba4c8253ef9d49..00000000000000 --- a/web/app/components/base/form/components/field/__tests__/text-area.spec.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { ComponentProps } from 'react' -import { fireEvent, render, screen } from '@testing-library/react' -import TextAreaField from '../text-area' - -const mockField = { - name: 'text-area-field', - state: { - value: 'Initial note', - }, - handleChange: vi.fn(), - handleBlur: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -describe('TextAreaField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = 'Initial note' - }) - - it('should render current value', () => { - render() - expect(screen.getByLabelText('Note')).toHaveValue('Initial note') - }) - - it('should update value when users type', () => { - render() - fireEvent.change(screen.getByLabelText('Note'), { target: { value: 'Updated note' } }) - expect(mockField.handleChange).toHaveBeenCalledWith('Updated note') - }) - - it('should keep form writeback when external props contain onValueChange', () => { - const externalOnValueChange = vi.fn() - - render( - - >)} - />, - ) - - fireEvent.change(screen.getByLabelText('Note'), { target: { value: 'Updated note' } }) - - expect(mockField.handleChange).toHaveBeenCalledWith('Updated note') - expect(externalOnValueChange).not.toHaveBeenCalled() - }) -}) diff --git a/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx b/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx deleted file mode 100644 index 852a2f72551106..00000000000000 --- a/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import InputTypeSelectField from '../index' - -const mockField = { - name: 'input-type', - state: { - value: 'text-input', - }, - handleChange: vi.fn(), -} - -vi.mock('../../../..', () => ({ - useFieldContext: () => mockField, -})) - -describe('InputTypeSelectField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = 'text-input' - }) - - it('should render label and selected option', () => { - const { container } = render() - - expect(screen.getByText('Input type')).toBeInTheDocument() - expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument() - expect(container.querySelector('[role="combobox"] span > div')).not.toBeInTheDocument() - expect(container.querySelector('[role="combobox"] > span > span')).toHaveClass( - 'flex', - 'min-w-0', - 'items-center', - 'gap-x-0.5', - ) - }) - - it('should update value when users choose another input type', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByRole('combobox', { name: 'Input type' })) - await user.click(screen.getByRole('option', { name: /appDebug.variableConfig.number/ })) - - expect(mockField.handleChange).toHaveBeenCalledWith('number') - }) -}) diff --git a/web/app/components/base/form/components/form/__tests__/actions.spec.tsx b/web/app/components/base/form/components/form/__tests__/actions.spec.tsx index cba58ad42b29e4..27cf13a03e8cc2 100644 --- a/web/app/components/base/form/components/form/__tests__/actions.spec.tsx +++ b/web/app/components/base/form/components/form/__tests__/actions.spec.tsx @@ -1,103 +1,75 @@ -import type { FormType } from '../../..' import type { CustomActionsProps } from '../actions' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { formContext } from '../../..' -import Actions from '../actions' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useAppForm } from '../../..' -const mockFormState = vi.hoisted(() => ({ - canSubmit: true, - isSubmitting: false, -})) - -vi.mock('@tanstack/react-form', async () => { - const actual = - await vi.importActual('@tanstack/react-form') - return { - ...actual, - useStore: (_store: unknown, selector: (state: typeof mockFormState) => unknown) => - selector(mockFormState), - } -}) - -type RenderWithFormOptions = { - canSubmit?: boolean - isSubmitting?: boolean - CustomActions?: (props: CustomActionsProps) => React.ReactNode - onSubmit?: () => void -} - -const renderWithForm = ({ - canSubmit = true, - isSubmitting = false, +const TestForm = ({ + invalid = false, + onSubmit = async () => {}, CustomActions, - onSubmit = vi.fn(), -}: RenderWithFormOptions = {}) => { - mockFormState.canSubmit = canSubmit - mockFormState.isSubmitting = isSubmitting - - const form = { - store: {}, - handleSubmit: onSubmit, - } - - render( - - - , +}: { + invalid?: boolean + onSubmit?: () => Promise + CustomActions?: (props: CustomActionsProps) => React.ReactNode +}) => { + const form = useAppForm({ + defaultValues: { name: '' }, + validators: { onMount: () => (invalid ? 'Name is required' : undefined) }, + onSubmit, + }) + return ( + + + ) - - return { onSubmit } } describe('Actions', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should disable submit button when form cannot submit', () => { - renderWithForm({ canSubmit: false }) - expect(screen.getByRole('button', { name: 'common.operation.submit' })).toBeDisabled() - }) - - it('should keep the pending submit button focusable when canSubmit includes submitting state', () => { - renderWithForm({ canSubmit: false, isSubmitting: true }) - const submitButton = screen.getByRole('button', { name: 'common.operation.submit' }) - - expect(submitButton).not.toBeDisabled() - expect(submitButton).toHaveAttribute('aria-disabled', 'true') + it('disables submission when the form is invalid', async () => { + render() + await waitFor(() => + expect(screen.getByRole('button', { name: 'common.operation.submit' })).toBeDisabled(), + ) }) - it('should call form submit when users click submit button', async () => { - const submitSpy = vi.fn() - renderWithForm({ onSubmit: submitSpy }) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.submit' })) - - await waitFor(() => { - expect(submitSpy).toHaveBeenCalledTimes(1) + it('keeps the pending button focusable and prevents duplicate submissions', async () => { + const user = userEvent.setup() + let finishSubmit!: () => void + const pending = new Promise((resolve) => { + finishSubmit = resolve }) + const onSubmit = vi.fn(() => pending) + render() + const button = screen.getByRole('button', { name: 'common.operation.submit' }) + await user.click(button) + await waitFor(() => expect(button).toHaveAttribute('aria-disabled', 'true')) + expect(button).not.toBeDisabled() + expect(button).toHaveFocus() + await user.click(button) + expect(onSubmit).toHaveBeenCalledOnce() + await act(async () => finishSubmit()) + await waitFor(() => expect(button).not.toHaveAttribute('aria-disabled', 'true')) }) - it('should render custom actions when provided', () => { - const customActionsSpy = vi.fn(({ isSubmitting, canSubmit }: CustomActionsProps) => ( -
{`custom-${String(isSubmitting)}-${String(canSubmit)}`}
- )) - - renderWithForm({ - CustomActions: customActionsSpy, - }) + it('submits through the supplied form', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn(async () => {}) + render() + await user.click(screen.getByRole('button', { name: 'common.operation.submit' })) + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()) + }) + it('provides form state to custom actions', () => { + render( + ( +
{`custom-${String(isSubmitting)}-${String(canSubmit)}`}
+ )} + />, + ) expect( screen.queryByRole('button', { name: 'common.operation.submit' }), ).not.toBeInTheDocument() expect(screen.getByText('custom-false-true')).toBeInTheDocument() - expect(customActionsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - form: expect.any(Object), - isSubmitting: false, - canSubmit: true, - }), - ) }) }) diff --git a/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts b/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts index fcd44db1149a3c..d2e2af9e9a36d7 100644 --- a/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts +++ b/web/app/components/base/form/hooks/__tests__/use-get-form-values.spec.ts @@ -1,237 +1,68 @@ -import type { AnyFormApi } from '@tanstack/react-form' -import { renderHook } from '@testing-library/react' +import { useField, useForm } from '@tanstack/react-form' +import { act, renderHook } from '@testing-library/react' +import { toast } from '@/app/notifications' import { FormTypeEnum } from '../../types' import { useGetFormValues } from '../use-get-form-values' -const mockCheckValidated = vi.fn() -const mockTransform = vi.fn() - -vi.mock('../use-check-validated', () => ({ - useCheckValidated: () => ({ - checkValidated: mockCheckValidated, - }), -})) - -vi.mock('../../utils/secret-input', () => ({ - getTransformedValuesWhenSecretInputPristine: (...args: unknown[]) => mockTransform(...args), -})) - -describe('useGetFormValues', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return raw values when validation check is disabled', () => { - const form = { - store: { state: { values: { name: 'Alice' } } }, - } - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, [])) - - expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({ - values: { name: 'Alice' }, - isCheckValidated: true, +const setup = () => + renderHook(() => { + const form = useForm({ defaultValues: { name: 'Alice', password: 'saved-secret' } }) + const name = useField({ + form, + name: 'name', + validators: { onChange: ({ value }) => (value ? undefined : 'Name required') }, }) + const password = useField({ form, name: 'password' }) + const onInvalid = vi.fn() + const values = useGetFormValues( + form, + [ + { name: 'name', label: 'Name', required: true, type: FormTypeEnum.textInput }, + { name: 'password', label: 'Password', required: true, type: FormTypeEnum.secretInput }, + ], + onInvalid, + ) + return { form, name, password, onInvalid, ...values } }) - it('should return transformed values when validation passes and transform is requested', () => { - const form = { - store: { state: { values: { password: 'abc123' } } }, - } - const schemas = [ - { - name: 'password', - label: 'Password', - required: true, - type: FormTypeEnum.secretInput, - }, - ] - mockCheckValidated.mockReturnValue(true) - mockTransform.mockReturnValue({ password: '[__HIDDEN__]' }) - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, schemas)) - - expect( - result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: true, - }), - ).toEqual({ - values: { password: '[__HIDDEN__]' }, - isCheckValidated: true, - }) +it('masks saved secrets until they are edited without altering the form data', () => { + const { result } = setup() + const options = { needTransformWhenSecretFieldIsPristine: true } + expect(result.current.getFormValues(options)).toEqual({ + isCheckValidated: true, + values: { name: 'Alice', password: '[__HIDDEN__]' }, }) - - it('should return empty values when validation fails', () => { - const form = { - store: { state: { values: { name: '' } } }, - } - mockCheckValidated.mockReturnValue(false) - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, [])) - - expect(result.current.getFormValues({ needCheckValidatedValues: true })).toEqual({ - values: {}, - isCheckValidated: false, - }) - }) - - it('should return raw values when validation passes but no transformation is requested', () => { - const form = { - store: { state: { values: { email: 'test@example.com' } } }, - } - const schemas = [ - { - name: 'email', - label: 'Email', - required: true, - type: FormTypeEnum.textInput, - }, - ] - mockCheckValidated.mockReturnValue(true) - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, schemas)) - - expect( - result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: false, - }), - ).toEqual({ - values: { email: 'test@example.com' }, - isCheckValidated: true, - }) - expect(mockTransform).not.toHaveBeenCalled() + expect(result.current.form.state.values.password).toBe('saved-secret') + act(() => result.current.password.handleChange('new-secret')) + expect(result.current.getFormValues(options)).toEqual({ + isCheckValidated: true, + values: { name: 'Alice', password: 'new-secret' }, }) +}) - it('should return raw values when validation passes and transformation is undefined', () => { - const form = { - store: { state: { values: { username: 'john_doe' } } }, - } - const schemas = [ - { - name: 'username', - label: 'Username', - required: true, - type: FormTypeEnum.textInput, - }, - ] - mockCheckValidated.mockReturnValue(true) - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, schemas)) - - expect( - result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: undefined, - }), - ).toEqual({ - values: { username: 'john_doe' }, - isCheckValidated: true, - }) - expect(mockTransform).not.toHaveBeenCalled() - }) - - it('should handle empty form values when validation check is disabled', () => { - const form = { - store: { state: { values: {} } }, - } - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, [])) - - expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({ - values: {}, - isCheckValidated: true, - }) - expect(mockCheckValidated).not.toHaveBeenCalled() - }) - - it('should handle null form values gracefully', () => { - const form = { - store: { state: { values: null } }, - } - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, [])) - - expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({ - values: {}, - isCheckValidated: true, - }) +it('rejects invalid values and allows submission after the field is corrected', () => { + const error = vi.spyOn(toast, 'error').mockReturnValue('error') + const { result } = setup() + act(() => result.current.name.handleChange('')) + expect(result.current.getFormValues({})).toEqual({ values: {}, isCheckValidated: false }) + expect(error).toHaveBeenCalledWith('Name required') + expect(result.current.onInvalid).toHaveBeenCalledWith('name') + act(() => result.current.name.handleChange('Bob')) + expect(result.current.getFormValues({})).toEqual({ + values: { name: 'Bob', password: 'saved-secret' }, + isCheckValidated: true, }) +}) - it('should call transform with correct arguments when transformation is requested', () => { - const form = { - store: { state: { values: { password: 'secret' } } }, - } - const schemas = [ - { - name: 'password', - label: 'Password', - required: true, - type: FormTypeEnum.secretInput, - }, - ] - mockCheckValidated.mockReturnValue(true) - mockTransform.mockReturnValue({ password: 'encrypted' }) - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, schemas)) - +it('returns raw values without validation or masking when validation is explicitly skipped', () => { + const error = vi.spyOn(toast, 'error').mockReturnValue('error') + const { result } = setup() + act(() => result.current.name.handleChange('')) + expect( result.current.getFormValues({ - needCheckValidatedValues: true, + needCheckValidatedValues: false, needTransformWhenSecretFieldIsPristine: true, - }) - - expect(mockTransform).toHaveBeenCalledWith(schemas, form) - }) - - it('should return validation failure before attempting transformation', () => { - const form = { - store: { state: { values: { password: 'secret' } } }, - } - const schemas = [ - { - name: 'password', - label: 'Password', - required: true, - type: FormTypeEnum.secretInput, - }, - ] - mockCheckValidated.mockReturnValue(false) - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, schemas)) - - expect( - result.current.getFormValues({ - needCheckValidatedValues: true, - needTransformWhenSecretFieldIsPristine: true, - }), - ).toEqual({ - values: {}, - isCheckValidated: false, - }) - expect(mockTransform).not.toHaveBeenCalled() - }) - - it('should handle complex nested values with validation check disabled', () => { - const form = { - store: { - state: { - values: { - user: { name: 'Alice', age: 30 }, - settings: { theme: 'dark' }, - }, - }, - }, - } - - const { result } = renderHook(() => useGetFormValues(form as AnyFormApi, [])) - - expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({ - values: { - user: { name: 'Alice', age: 30 }, - settings: { theme: 'dark' }, - }, - isCheckValidated: true, - }) - }) + }), + ).toEqual({ values: { name: '', password: 'saved-secret' }, isCheckValidated: true }) + expect(error).not.toHaveBeenCalled() }) diff --git a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts index e59dce6cc7dd43..2cedf138151497 100644 --- a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts @@ -14,13 +14,12 @@ vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: undefined }), })) -const { mockImageUpload, mockGetImageUploadErrorMessage } = vi.hoisted(() => ({ +const { mockImageUpload } = vi.hoisted(() => ({ mockImageUpload: vi.fn(), - mockGetImageUploadErrorMessage: vi.fn(() => 'Upload error'), })) -vi.mock('../utils', () => ({ +vi.mock('../utils', async (importOriginal) => ({ + ...(await importOriginal()), imageUpload: mockImageUpload, - getImageUploadErrorMessage: mockGetImageUploadErrorMessage, })) let fileCounter = 0 @@ -283,7 +282,10 @@ describe('useImageFiles', () => { }) expect(result.current.files[0]!.progress).toBe(-1) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Upload error' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.imageUploader.uploadFromComputerUploadError', + }) }) }) diff --git a/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx index aa9ef2c15d6e2f..ff0875f939d32f 100644 --- a/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/button.spec.tsx @@ -8,12 +8,6 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ChatContextProvider } from '@/app/components/base/chat/chat/context-provider' import MarkdownButton from '../button' -// Only mock the URL utility so behavior is deterministic -const isValidUrlSpy = vi.fn() -vi.mock('../utils', () => ({ - isValidUrl: (u: string) => isValidUrlSpy(u), -})) // test subject - type TestNode = NonNullable function createButtonNode(properties: TestNode['properties'], label?: string): TestNode { @@ -53,14 +47,11 @@ describe('MarkdownButton (integration)', () => { }) it('renders a native link when the URL is valid', () => { - isValidUrlSpy.mockReturnValue(true) - const node = createButtonNode({ dataLink: 'https://example.com' }, 'Go') renderWithCtx(node) const link = screen.getByRole('link', { name: 'Go' }) - expect(isValidUrlSpy).toHaveBeenCalledWith('https://example.com') expect(link).toHaveAttribute('href', 'https://example.com') expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', 'noopener noreferrer') @@ -68,7 +59,6 @@ describe('MarkdownButton (integration)', () => { }) it('calls onSend when link is invalid but message exists', async () => { - isValidUrlSpy.mockReturnValue(false) const user = userEvent.setup() const node = createButtonNode({ dataLink: 'not-a-url', dataMessage: 'hello!' }, 'Send') @@ -76,20 +66,17 @@ describe('MarkdownButton (integration)', () => { renderWithCtx(node) await user.click(screen.getByRole('button')) - expect(isValidUrlSpy).toHaveBeenCalledWith('not-a-url') expect(onSendSpy).toHaveBeenCalledTimes(1) expect(onSendSpy).toHaveBeenCalledWith('hello!') }) it('does nothing when no link and no message', async () => { - isValidUrlSpy.mockReturnValue(false) const user = userEvent.setup() const node = createButtonNode({}, 'Empty') renderWithCtx(node) await user.click(screen.getByRole('button')) - expect(isValidUrlSpy).not.toHaveBeenCalled() expect(onSendSpy).not.toHaveBeenCalled() }) diff --git a/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx index 42e398323a7475..58ff89ea24bb0f 100644 --- a/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx @@ -12,11 +12,6 @@ vi.mock('@/app/components/base/chat/chat/context', () => ({ }), })) -const mockIsValidUrl = vi.fn() -vi.mock('../utils', () => ({ - isValidUrl: (url: string) => mockIsValidUrl(url), -})) - describe('Link component', () => { beforeEach(() => { vi.clearAllMocks() @@ -134,8 +129,6 @@ describe('Link component', () => { // INVALID URL // -------------------------- it('renders span when url is invalid', () => { - mockIsValidUrl.mockReturnValue(false) - const node = { properties: { href: 'not-a-url', @@ -152,8 +145,6 @@ describe('Link component', () => { // VALID EXTERNAL URL // -------------------------- it('renders external link with target blank when url is valid', () => { - mockIsValidUrl.mockReturnValue(true) - const node = { properties: { href: 'https://example.com', @@ -171,8 +162,6 @@ describe('Link component', () => { }) it('adds attachment mode to file-preview links by default', () => { - mockIsValidUrl.mockReturnValue(true) - const node = { properties: { href: 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3', @@ -188,8 +177,6 @@ describe('Link component', () => { }) it('does not duplicate attachment mode for file-preview links', () => { - mockIsValidUrl.mockReturnValue(true) - const node = { properties: { href: 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3&as_attachment=true', @@ -205,8 +192,6 @@ describe('Link component', () => { }) it('keeps protocol-relative file-preview links protocol-relative', () => { - mockIsValidUrl.mockReturnValue(true) - const node = { properties: { href: '//localhost:5001/files/123/file-preview?timestamp=1#page', @@ -239,8 +224,6 @@ describe('Link component', () => { // DEFAULT TEXT FALLBACK // -------------------------- it('renders default text for external link if children not provided', () => { - mockIsValidUrl.mockReturnValue(true) - const node = { properties: { href: 'https://example.com', diff --git a/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx index e9e19f39d31409..ae0f565bdeee07 100644 --- a/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/music.spec.tsx @@ -93,25 +93,5 @@ describe('MarkdownMusic', () => { }), ) }) - - it('should skip initialization when refs are unavailable', async () => { - vi.doMock('react', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useEffect: (effect: () => void) => { - effect() - }, - } - }) - const MarkdownMusic = await loadMarkdownMusic() - render({'X:1\nT:NoRef\nK:C\nC D E F|'}) - - expect(mockRenderAbc).not.toHaveBeenCalled() - expect(mockLoad).not.toHaveBeenCalled() - expect(mockInit).not.toHaveBeenCalled() - - vi.doUnmock('react') - }) }) }) diff --git a/web/app/components/base/mermaid/__tests__/index.spec.tsx b/web/app/components/base/mermaid/__tests__/index.spec.tsx index 30a4f712ab399a..ed51ec122d6aee 100644 --- a/web/app/components/base/mermaid/__tests__/index.spec.tsx +++ b/web/app/components/base/mermaid/__tests__/index.spec.tsx @@ -695,50 +695,6 @@ describe('Mermaid Flowchart Component Module Isolation', () => { } }) - it.skip('should show container-not-found error when container ref remains null', async () => { - vi.resetModules() - vi.doMock('react', async () => { - const reactActual = await vi.importActual('react') - let pendingContainerRef: ReturnType | null = null - let patchedContainerRef = false - const mockedUseRef = ((initialValue: unknown) => { - const ref = reactActual.useRef(initialValue as never) - if (!patchedContainerRef && initialValue === null) pendingContainerRef = ref - - if ( - !patchedContainerRef && - pendingContainerRef && - typeof initialValue === 'string' && - initialValue.startsWith('mermaid-chart-') - ) { - Object.defineProperty(pendingContainerRef, 'current', { - configurable: true, - get() { - return null - }, - set(_value: HTMLDivElement | null) {}, - }) - patchedContainerRef = true - pendingContainerRef = null - } - return ref - }) as typeof reactActual.useRef - - return { - ...reactActual, - useRef: mockedUseRef, - } - }) - - try { - const { default: FlowchartFresh } = await import('../index') - render() - expect(await screen.findByText('Container element not found')).toBeInTheDocument() - } finally { - vi.doUnmock('react') - } - }) - it('should cancel a pending classic render on unmount', async () => { const { default: FlowchartFresh } = await import('../index') diff --git a/web/app/components/base/message-log-modal/__tests__/index.spec.tsx b/web/app/components/base/message-log-modal/__tests__/index.spec.tsx index 41a855a10edc14..a7d3be00b49a79 100644 --- a/web/app/components/base/message-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/message-log-modal/__tests__/index.spec.tsx @@ -2,13 +2,6 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import { fireEvent, render, screen } from '@testing-library/react' import MessageLogModal from '../index' -let clickAwayHandler: (() => void) | null = null -vi.mock('ahooks', () => ({ - useClickAway: (fn: () => void) => { - clickAwayHandler = fn - }, -})) - vi.mock('@/app/components/workflow/run', () => ({ default: ({ activeTab, @@ -40,7 +33,6 @@ describe('MessageLogModal', () => { beforeEach(() => { vi.clearAllMocks() - clickAwayHandler = null }) describe('Render', () => { @@ -145,17 +137,15 @@ describe('MessageLogModal', () => { fixedWidth />, ) - expect(clickAwayHandler).toBeTruthy() - clickAwayHandler!() + fireEvent.click(document.body) expect(onCancel).toHaveBeenCalledTimes(1) }) - it('does not use click away to close the floating dialog', () => { + it('keeps the floating dialog open when its content is clicked', () => { render( , ) - expect(clickAwayHandler).toBeTruthy() - clickAwayHandler!() + fireEvent.click(screen.getByRole('dialog')) expect(onCancel).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx index db01df3e76a186..905b097c2695be 100644 --- a/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx +++ b/web/app/components/base/notion-page-selector/__tests__/base.spec.tsx @@ -1,10 +1,11 @@ import type { DatasourceCredentialResponse } from '@dify/contracts/api/console/auth/types.gen' import type { DataSourceNotionWorkspace } from '@/models/common' -import { render, screen, waitFor } from '@testing-library/react' +import { render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import { consoleQuery } from '@/service/console' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import NotionPageSelector from '../base' vi.mock('@tanstack/react-virtual') @@ -20,12 +21,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => ({ useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), })) -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - const buildCredential = ( id: string, name: string, @@ -111,6 +106,9 @@ const createPreImportResult = ({ isError, }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('NotionPageSelector Base', () => { beforeEach(() => { vi.clearAllMocks() @@ -133,7 +131,9 @@ describe('NotionPageSelector Base', () => { const connectButton = screen.getByRole('button', { name: 'datasetCreation.stepOne.connect' }) await user.click(connectButton) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) it('should render page selector and allow selecting a page tree', async () => { @@ -220,7 +220,9 @@ describe('NotionPageSelector Base', () => { await user.click( screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }), ) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) it('should preview a page and call onPreview when callback is provided', async () => { diff --git a/web/app/components/base/prompt-editor/__tests__/utils.spec.ts b/web/app/components/base/prompt-editor/__tests__/utils.spec.ts index 126b5a4c4a32ee..a788fa3f961bed 100644 --- a/web/app/components/base/prompt-editor/__tests__/utils.spec.ts +++ b/web/app/components/base/prompt-editor/__tests__/utils.spec.ts @@ -25,10 +25,6 @@ vi.mock('lexical', async (importOriginal) => { } }) -vi.mock('./plugins/custom-text/node', () => ({ - CustomTextNode: class MockCustomTextNode {}, -})) - describe('prompt-editor/utils', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx index 2c3dad2fb71fa2..04244b04aa68e6 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field-keyboard.spec.tsx @@ -1,16 +1,21 @@ import { detectPlatform } from '@tanstack/react-hotkeys' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { TransferMethod } from '@/types/app' import InputField from '../input-field' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'files.upload.get': () => ({ data: undefined }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'files.upload.get': async () => undefined, + }), + } }) it('lets the extension input commit its tag before the field shortcut saves', async () => { diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx index f70f16c604e944..0b63ef9ce78e34 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/__tests__/component.spec.tsx @@ -24,47 +24,15 @@ const render: typeof renderWithConsoleQuery = (ui, options) => { return renderWithConsoleQuery(ui, { ...options, queryClient }) } -const { mockVarLabel, mockIsExceptionVariable, mockForcedVariableKind } = vi.hoisted(() => ({ +const { mockVarLabel } = vi.hoisted(() => ({ mockVarLabel: vi.fn(), - mockIsExceptionVariable: vi.fn<(variable: string, nodeType?: BlockEnum) => boolean>(() => false), - mockForcedVariableKind: { value: '' as '' | 'env' | 'conversation' | 'rag' }, })) vi.mock('@lexical/react/LexicalComposerContext') vi.mock('@lexical/utils') vi.mock('reactflow') vi.mock('../../../hooks') -vi.mock('@/app/components/workflow/utils', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - isExceptionVariable: mockIsExceptionVariable, - } -}) -vi.mock( - '@/app/components/workflow/nodes/_base/components/variable/utils', - async (importOriginal) => { - const actual = - await importOriginal< - typeof import('@/app/components/workflow/nodes/_base/components/variable/utils') - >() - return { - ...actual, - isENV: (valueSelector: ValueSelector) => { - if (mockForcedVariableKind.value === 'env') return true - return actual.isENV(valueSelector) - }, - isConversationVar: (valueSelector: ValueSelector) => { - if (mockForcedVariableKind.value === 'conversation') return true - return actual.isConversationVar(valueSelector) - }, - isRagVariableVar: (valueSelector: ValueSelector) => { - if (mockForcedVariableKind.value === 'rag') return true - return actual.isRagVariableVar(valueSelector) - }, - } - }, -) + vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({ VariableLabelInEditor: (props: { onClick: (e: React.MouseEvent) => void @@ -100,7 +68,6 @@ const mockEditor = { describe('WorkflowVariableBlockComponent', () => { beforeEach(() => { vi.clearAllMocks() - mockForcedVariableKind.value = '' mockHasNodes.mockReturnValue(true) mockRegisterCommand.mockReturnValue(vi.fn()) mockGetState.mockReturnValue({ transform: [0, 0, 2] }) @@ -306,26 +273,6 @@ describe('WorkflowVariableBlockComponent', () => { ) }) - it('should mark forced env branch invalid when selector prefix is missing', () => { - mockForcedVariableKind.value = 'env' - const environmentVariables: Var[] = [{ variable: '.', type: VarType.string }] - - render( - , - ) - - expect(mockVarLabel).toHaveBeenCalledWith( - expect.objectContaining({ - errorMsg: expect.any(String), - }), - ) - }) - it('should treat conversation variable as valid when found in conversationVariables', () => { const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }] @@ -399,26 +346,6 @@ describe('WorkflowVariableBlockComponent', () => { ) }) - it('should mark forced conversation branch invalid when selector prefix is missing', () => { - mockForcedVariableKind.value = 'conversation' - const conversationVariables: Var[] = [{ variable: '.', type: VarType.string }] - - render( - , - ) - - expect(mockVarLabel).toHaveBeenCalledWith( - expect.objectContaining({ - errorMsg: expect.any(String), - }), - ) - }) - it('should treat global variable as valid without node', () => { render( { ) }) - it('should mark forced rag branch invalid when selector prefix is missing', () => { - mockForcedVariableKind.value = 'rag' - const ragVariables: Var[] = [{ variable: '..', type: VarType.string }] - - render( - , - ) - - expect(mockVarLabel).toHaveBeenCalledWith( - expect.objectContaining({ - errorMsg: expect.any(String), - }), - ) - }) - it('should apply workflow node map updates through command handler', () => { render( void)[] = [] -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useClickAway: vi.fn((fn: () => void) => { - clickAwayHandlers.push(fn) - }), - } -}) - describe('PromptLogModal', () => { const defaultProps = { width: 1000, @@ -26,7 +14,6 @@ describe('PromptLogModal', () => { beforeEach(() => { vi.clearAllMocks() - clickAwayHandlers = [] }) describe('Render', () => { @@ -89,23 +76,15 @@ describe('PromptLogModal', () => { const onCancel = vi.fn() render() - expect(useClickAway).toHaveBeenCalled() - expect(clickAwayHandlers.length).toBeGreaterThan(0) - - // Call the last registered handler (simulating click away) - // Call the last registered handler (simulating click away) - clickAwayHandlers[clickAwayHandlers.length - 1]!() - expect(onCancel).toHaveBeenCalled() + fireEvent.click(document.body) + expect(onCancel).toHaveBeenCalledOnce() }) - it('does not call onCancel when clicking outside if not mounted', () => { + it('does not close when clicking log content', () => { const onCancel = vi.fn() render() - expect(clickAwayHandlers.length).toBeGreaterThan(0) - // The first handler in the array is captured during the initial render before useEffect runs - // The first handler in the array is captured during the initial render before useEffect runs - clickAwayHandlers[0]!() + fireEvent.click(screen.getByText('Hello')) expect(onCancel).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/base/svg-gallery/__tests__/index.browser.spec.tsx b/web/app/components/base/svg-gallery/__tests__/index.browser.spec.tsx new file mode 100644 index 00000000000000..4b10403042fd50 --- /dev/null +++ b/web/app/components/base/svg-gallery/__tests__/index.browser.spec.tsx @@ -0,0 +1,39 @@ +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import SVGRenderer from '..' + +const validSvg = + '' + +// Native SVG parsing and DOMPurify must agree on namespaces and preserve renderable +// geometry while removing executable content; happy-dom does not preserve this contract. +describe('SVGRenderer', () => { + it('renders safe SVG geometry after removing scripts and event handlers', async () => { + const { container } = await render( + , + ) + + const circle = container.querySelector('circle') + expect(circle).toBeInstanceOf(SVGCircleElement) + expect(circle?.getBoundingClientRect().width).toBeGreaterThan(0) + expect(container.querySelector('script, [onload]')).toBeNull() + }) + + it('opens and closes a rendered SVG preview', async () => { + const { container } = await render() + const circle = container.querySelector('circle')! + await page.elementLocator(circle).click() + + await expect.element(page.getByRole('img', { name: 'Preview' })).toBeVisible() + await expect + .element(page.getByRole('img', { name: 'Preview' })) + .toHaveAttribute('src', expect.stringContaining('data:image/svg+xml;base64,')) + await page.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(page.getByRole('img', { name: 'Preview' })).not.toBeInTheDocument() + }) + + it('shows the generation fallback for invalid SVG', async () => { + await render() + await expect.element(page.getByText('common.svgRenderer.generatingImage')).toBeVisible() + }) +}) diff --git a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx b/web/app/components/base/svg-gallery/__tests__/index.spec.tsx deleted file mode 100644 index 99923d84296762..00000000000000 --- a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { act, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import SVGRenderer from '..' - -const mockClick = vi.fn() -const mockSvg = vi.fn().mockReturnValue({ - click: mockClick, -}) -const mockViewbox = vi.fn() -const mockAddTo = vi.fn() - -vi.mock('@svgdotjs/svg.js', () => ({ - SVG: vi.fn().mockImplementation(() => ({ - addTo: mockAddTo, - })), -})) - -vi.mock('dompurify', () => ({ - default: { - sanitize: vi.fn((content) => content), - }, -})) - -describe('SVGRenderer', () => { - const validSvg = '' - let parseFromStringSpy: ReturnType - beforeEach(() => { - vi.clearAllMocks() - mockAddTo.mockReturnValue({ - viewbox: mockViewbox, - svg: mockSvg, - }) - mockSvg.mockReturnValue({ - click: mockClick, - }) - - const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg') - mockSvgElement.setAttribute('width', '100') - mockSvgElement.setAttribute('height', '100') - parseFromStringSpy = vi.spyOn(DOMParser.prototype, 'parseFromString').mockReturnValue({ - documentElement: mockSvgElement, - } as unknown as Document) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe('Rendering', () => { - it('renders correctly with content', async () => { - render() - - await waitFor(() => { - expect(mockViewbox).toHaveBeenCalledWith(0, 0, 100, 100) - }) - expect(mockSvg).toHaveBeenCalledWith(validSvg) - }) - - it('shows error message on invalid SVG content', async () => { - parseFromStringSpy.mockReturnValue({ - documentElement: document.createElement('div'), - } as unknown as Document) - - render() - - await waitFor(() => { - expect(screen.getByText('common.svgRenderer.generatingImage'))!.toBeInTheDocument() - }) - }) - - it('uses default values for width/height if not present', async () => { - const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg') - parseFromStringSpy.mockReturnValue({ - documentElement: mockSvgElement, - } as unknown as Document) - - render() - - await waitFor(() => { - expect(mockViewbox).toHaveBeenCalledWith(0, 0, 400, 600) - }) - }) - }) - - describe('Image Preview Interactions', () => { - it('opens image preview on click', async () => { - render() - - await waitFor(() => { - expect(mockClick).toHaveBeenCalled() - }) - const clickHandler = mockClick.mock.calls[0]![0] - - await act(async () => { - clickHandler() - }) - const img = screen.getByAltText('Preview') - expect(img)!.toBeInTheDocument() - expect(img)!.toHaveAttribute('src', expect.stringContaining('data:image/svg+xml;base64')) - }) - - it('closes image preview on cancel', async () => { - const user = userEvent.setup() - render() - - await waitFor(() => { - expect(mockClick).toHaveBeenCalled() - }) - const clickHandler = mockClick.mock.calls[0]![0] - await act(async () => { - clickHandler() - }) - - expect(screen.getByAltText('Preview'))!.toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - await waitFor(() => { - expect(screen.queryByAltText('Preview')).not.toBeInTheDocument() - }) - }) - }) -}) diff --git a/web/app/components/base/zendesk/__tests__/index.spec.tsx b/web/app/components/base/zendesk/__tests__/index.spec.tsx index 2e25a97296f7ab..7a52c9faa407d8 100644 --- a/web/app/components/base/zendesk/__tests__/index.spec.tsx +++ b/web/app/components/base/zendesk/__tests__/index.spec.tsx @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ scriptProps: vi.fn(), })) -// Mock react's memo to just return the function +// happy-dom cannot render async server components; unwrap memo only to invoke the server boundary. vi.mock('react', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index ac3fc103f0a70c..c3d54f1eefaba0 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -19,13 +19,6 @@ let deploymentEdition: GetSystemFeaturesResponse['deployment_edition'] = 'COMMUN let canReplaceLogo = true let plan: CloudPlan = 'professional' -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - } -}) - function renderWithData(ui: ReactElement) { const queryClient = createConsoleQueryClient() queryClient.setQueryData(consoleQuery.workspaces.customConfig.get.queryKey(), { diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx index ef6a6bc4577de2..97f8525d37ca32 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -1,9 +1,10 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' import type { ChangeEvent } from 'react' import type { ConsoleStateFixture } from '@/test/console/state-fixture' +import { ORPCError } from '@orpc/client' import { act, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' +import { imageUpload } from '@/app/components/base/image-uploader/utils' import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data' import useWebAppBrand from '../use-web-app-brand' @@ -115,13 +116,12 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/app/components/base/image-uploader/utils', () => ({ +vi.mock('@/app/components/base/image-uploader/utils', async (importOriginal) => ({ + ...(await importOriginal()), imageUpload: vi.fn(), - getImageUploadErrorMessage: vi.fn(), })) const mockImageUpload = vi.mocked(imageUpload) -const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) const testUserProfile = { id: '', @@ -165,7 +165,6 @@ describe('useWebAppBrand', () => { customConfigQueryError = undefined mockUpdateCustomConfig.mockResolvedValue(customConfig) canReplaceLogo = true - mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) // Derived state from context and store inputs. @@ -332,7 +331,7 @@ describe('useWebAppBrand', () => { it('should surface upload errors and set the failure state', () => { mockImageUpload.mockImplementation(({ onErrorCallback }) => { - onErrorCallback({ response: { code: 'forbidden' } }) + onErrorCallback(new ORPCError('forbidden', { message: 'Workspace upload denied' })) }) const { result } = renderHook(() => useWebAppBrand()) @@ -343,10 +342,9 @@ describe('useWebAppBrand', () => { } as unknown as ChangeEvent) }) - expect(mockGetImageUploadErrorMessage).toHaveBeenCalled() expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'upload error', + message: 'Workspace upload denied', }) expect(result.current.uploadProgress).toBe(-1) }) diff --git a/web/app/components/datasets/access-config/__tests__/index.spec.tsx b/web/app/components/datasets/access-config/__tests__/index.spec.tsx index 56461f77b22cd7..8910a2115c69bf 100644 --- a/web/app/components/datasets/access-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/access-config/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor' -import { act, screen } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import { useDatasetAccessRules, useDatasetResourceWhitelist, @@ -74,11 +74,10 @@ const mockMutations = vi.hoisted(() => ({ updateAutomaticIncludeWorkspaceMembers: vi.fn(), updateUserAccessSettings: vi.fn(), removeMemberBindings: vi.fn(), - removeMemberBindingsAsync: vi.fn(), - isUpdatingAutomaticIncludeWorkspaceMembers: false, })) -vi.mock('@/service/access-control/use-dataset-access-config', () => ({ +vi.mock('@/service/access-control/use-dataset-access-config', async (importOriginal) => ({ + ...(await importOriginal()), useDatasetAccessRules: vi.fn(() => ({ data: { items: mockDatasetAccessRules.items }, isLoading: mockDatasetAccessRules.isLoading, @@ -95,17 +94,6 @@ vi.mock('@/service/access-control/use-dataset-access-config', () => ({ useDatasetResourceWhitelistConfig: vi.fn(() => ({ data: mockDatasetResourceWhitelistConfig.data, })), - useUpdateDatasetAutomaticIncludeWorkspaceMembers: vi.fn(() => ({ - mutate: mockMutations.updateAutomaticIncludeWorkspaceMembers, - isPending: mockMutations.isUpdatingAutomaticIncludeWorkspaceMembers, - })), - useUpdateDatasetUserAccessSettings: vi.fn(() => ({ - mutate: mockMutations.updateUserAccessSettings, - })), - useRemoveDatasetAccessPolicyMemberBindings: vi.fn(() => ({ - mutate: mockMutations.removeMemberBindings, - mutateAsync: mockMutations.removeMemberBindingsAsync, - })), })) vi.mock('@/context/dataset-detail', () => ({ @@ -135,6 +123,22 @@ vi.mock('@/app/components/access-rules-editor', () => ({ }, })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.rbac.datasets.byDatasetId.whitelist.put': + mockMutations.updateAutomaticIncludeWorkspaceMembers, + 'workspaces.current.rbac.datasets.byDatasetId.users.byTargetAccountId.accessPolicies.put': + mockMutations.updateUserAccessSettings, + 'workspaces.current.rbac.datasets.byDatasetId.accessPolicies.byPolicyId.memberBindings.delete': + mockMutations.removeMemberBindings, + }), + } +}) + describe('DatasetAccessConfigPage', () => { beforeEach(() => { vi.clearAllMocks() @@ -155,8 +159,9 @@ describe('DatasetAccessConfigPage', () => { mockDatasetResourceWhitelistConfig.data = { automatic_include_workspace_members: false, } - mockMutations.isUpdatingAutomaticIncludeWorkspaceMembers = false - mockMutations.removeMemberBindingsAsync.mockResolvedValue(undefined) + mockMutations.removeMemberBindings.mockResolvedValue({}) + mockMutations.updateUserAccessSettings.mockResolvedValue({}) + mockMutations.updateAutomaticIncludeWorkspaceMembers.mockResolvedValue({}) mockDatasetDetail.dataset = { maintainer: 'maintainer-1', permission_keys: [DatasetACLPermission.AccessConfig], @@ -237,14 +242,16 @@ describe('DatasetAccessConfigPage', () => { expect(mockAccessRulesEditor.props?.pageSize).toBe(25) }) - it('should return to the first page when automatic member inclusion is disabled', () => { + it('should return to the first page when automatic member inclusion is disabled', async () => { mockDatasetResourceWhitelistConfig.data = { automatic_include_workspace_members: true, } render() act(() => mockAccessRulesEditor.props?.onPageChange?.(2)) - act(() => mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(false)) + await act(async () => { + mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(false) + }) expect(useDatasetUserAccessSettings).toHaveBeenLastCalledWith( 'dataset-1', @@ -255,7 +262,7 @@ describe('DatasetAccessConfigPage', () => { ) }) - it('should jump to the new last page after adding a member', () => { + it('should jump to the new last page after adding a member', async () => { mockDatasetUserAccessSettings.pagination = { current_page: 1, per_page: 10, @@ -268,12 +275,11 @@ describe('DatasetAccessConfigPage', () => { } render() - act(() => mockAccessRulesEditor.props?.onAddAccessSubject?.('account-11', ['default'])) + await act(async () => { + mockAccessRulesEditor.props?.onAddAccessSubject?.('account-11', ['default']) + }) - const callbacks = mockMutations.updateUserAccessSettings.mock.calls.at(-1)?.[1] as { - onSuccess: () => void - } - act(() => callbacks.onSuccess()) + await act(async () => {}) expect(useDatasetUserAccessSettings).toHaveBeenLastCalledWith( 'dataset-1', expect.any(String), @@ -369,21 +375,27 @@ describe('DatasetAccessConfigPage', () => { render() mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(true) - expect(mockMutations.updateAutomaticIncludeWorkspaceMembers).toHaveBeenCalledWith( - true, - expect.objectContaining({ onError: expect.any(Function), onSuccess: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.updateAutomaticIncludeWorkspaceMembers).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1' }, + body: { automatic_include_workspace_members: true }, + }), ) mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-1', ['policy-1']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( - { accountId: 'account-1', accessPolicyIds: ['policy-1'] }, - expect.objectContaining({ onSettled: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', target_account_id: 'account-1' }, + body: { access_policy_ids: ['policy-1'] }, + }), ) mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-3', 'policy-3') - expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith( - [{ accessPolicyId: 'policy-3', accountIds: ['account-3'] }], - expect.objectContaining({ onSettled: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', policy_id: 'policy-3' }, + body: { account_ids: ['account-3'] }, + }), ) const removals = [ @@ -393,8 +405,14 @@ describe('DatasetAccessConfigPage', () => { await act(async () => { await mockAccessRulesEditor.props?.onBatchRemoveAccessPolicyMemberBindings?.(removals) }) - expect(mockMutations.removeMemberBindingsAsync).toHaveBeenCalledOnce() - expect(mockMutations.removeMemberBindingsAsync).toHaveBeenCalledWith(removals) + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', policy_id: 'policy-1' }, + body: { account_ids: ['account-1', 'account-2'] }, + }) + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', policy_id: 'default' }, + body: { account_ids: ['account-4'] }, + }) }) it('should block member changes but allow permission updates during automatic inclusion', async () => { @@ -413,12 +431,13 @@ describe('DatasetAccessConfigPage', () => { expect(mockMutations.updateUserAccessSettings).not.toHaveBeenCalled() expect(mockMutations.removeMemberBindings).not.toHaveBeenCalled() - expect(mockMutations.removeMemberBindingsAsync).not.toHaveBeenCalled() mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-2', ['policy-2']) - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith( - { accountId: 'account-2', accessPolicyIds: ['policy-2'] }, - expect.objectContaining({ onSettled: expect.any(Function) }), + await waitFor(() => + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1', target_account_id: 'account-2' }, + body: { access_policy_ids: ['policy-2'] }, + }), ) }) }) diff --git a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx index a55579eabf54b6..29d23ad28f4cce 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx @@ -11,19 +11,6 @@ const { mockUseDocumentList } = vi.hoisted(() => ({ mockUseDocumentList: vi.fn(), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.get': (options: { - input: { params: { dataset_id: string }; query: Record } - }) => - mockUseDocumentList({ - datasetId: options.input.params.dataset_id, - query: options.input.query, - }), - }) -}) - const createDocument = ( overrides: Partial = {}, ): DocumentWithSegmentsResponse => ({ @@ -105,9 +92,15 @@ describe('DocumentPicker', () => { createDocument({ id: 'doc-2', name: 'Document 2' }), ], } - mockUseDocumentList.mockImplementation(() => ({ - data: mockDocumentListData, - })) + mockUseDocumentList.mockImplementation(() => + Promise.resolve({ + ...mockDocumentListData, + total: mockDocumentListData?.data.length ?? 0, + page: 1, + limit: 20, + has_more: false, + }), + ) }) it('should render the current document and chunking mode', () => { @@ -134,7 +127,7 @@ describe('DocumentPicker', () => { await waitFor(() => { expect(mockUseDocumentList).toHaveBeenLastCalledWith({ - datasetId: 'dataset-custom', + params: { dataset_id: 'dataset-custom' }, query: { keyword: 'report', page: 1, @@ -162,9 +155,16 @@ describe('DocumentPicker', () => { it('should keep focus in the search input when deleting from an empty result', async () => { const user = userEvent.setup() - mockUseDocumentList.mockImplementation(({ query }) => ({ - data: query.keyword === 'missing' ? { data: [] } : mockDocumentListData, - })) + mockUseDocumentList.mockImplementation(({ query }) => + Promise.resolve({ + ...mockDocumentListData, + data: query.keyword === 'missing' ? [] : mockDocumentListData?.data, + total: query.keyword === 'missing' ? 0 : mockDocumentListData?.data.length, + page: 1, + limit: 20, + has_more: false, + }), + ) renderDocumentPicker() @@ -228,3 +228,14 @@ describe('DocumentPicker', () => { ).toBeInTheDocument() }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.get': mockUseDocumentList, + }), + } +}) diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx index a4a0058fcd3546..462f5dddbcb01a 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/auto-disabled-document.spec.tsx @@ -11,13 +11,16 @@ const { mockToastSuccess, mockEnableDocuments } = vi.hoisted(() => ({ mockEnableDocuments: vi.fn(), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.status.byAction.batch.patch': () => ({ - mutateAsync: mockEnableDocuments, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': (input) => + mockEnableDocuments(input), }), - }) + } }) vi.mock('@/app/notifications', () => ({ diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx index 5c9a3abb4bf34b..5ea9ed260fd0ae 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/index-failed.spec.tsx @@ -1,317 +1,102 @@ -import type { ErrorDocsResponse } from '@dify/contracts/api/console/datasets/types.gen' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { retryErrorDocs } from '@/service/datasets' +import type { DocumentStatusResponse } from '@dify/contracts/api/console/datasets/types.gen' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import RetryButton from '../index-failed' -const mockRefetch = vi.fn() - -const { mockErrorDocsQuery } = vi.hoisted(() => ({ mockErrorDocsQuery: vi.fn() })) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.errorDocs.get': mockErrorDocsQuery, - }) -}) - -vi.mock('@/service/datasets', () => ({ +const { getErrorDocs, retryErrorDocs } = vi.hoisted(() => ({ + getErrorDocs: vi.fn(), retryErrorDocs: vi.fn(), })) - -const mockRetryErrorDocs = vi.mocked(retryErrorDocs) - -afterEach(() => { - cleanup() - vi.clearAllMocks() +vi.mock('@/service/datasets', () => ({ retryErrorDocs })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.errorDocs.get': getErrorDocs, + }), + } }) - -// Helper to create mock query result -const createMockQueryResult = (data: ErrorDocsResponse | undefined, isLoading: boolean) => ({ - data, - isLoading, - refetch: mockRefetch, - // Required query result properties - error: null, - isError: false, - isFetched: true, - isFetching: false, - isSuccess: !isLoading && !!data, - status: isLoading ? 'pending' : 'success', - dataUpdatedAt: Date.now(), - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isLoadingError: false, - isPaused: false, - isPlaceholderData: false, - isPending: isLoading, - isRefetchError: false, - isRefetching: false, - isStale: false, - fetchStatus: 'idle', - promise: Promise.resolve(data as ErrorDocsResponse), - isFetchedAfterMount: true, - isInitialLoading: false, +const document = (id: string): DocumentStatusResponse => ({ + id, + indexing_status: 'error', + processing_started_at: null, + parsing_completed_at: null, + cleaning_completed_at: null, + splitting_completed_at: null, + completed_at: null, + paused_at: null, + error: 'Indexing failed', + stopped_at: null, + completed_segments: 0, + total_segments: 1, }) +const errors = { total: 2, data: [document('doc1'), document('doc2')] } -describe('RetryButton (IndexFailed)', () => { +describe('RetryButton', () => { beforeEach(() => { vi.clearAllMocks() - mockRefetch.mockResolvedValue({}) + getErrorDocs.mockResolvedValue(errors) + retryErrorDocs.mockResolvedValue({ result: 'success' }) }) - - describe('Rendering', () => { - it('should render nothing when loading', () => { - mockErrorDocsQuery.mockReturnValue(createMockQueryResult(undefined, true)) - - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('should render nothing when no error documents', () => { - mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) - - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('should render StatusWithAction when error documents exist', () => { - mockErrorDocsQuery.mockReturnValue( - createMockQueryResult( - { - total: 3, - data: [{ id: 'doc1' }, { id: 'doc2' }, { id: 'doc3' }] as ErrorDocsResponse['data'], - }, - false, - ), - ) - - render() - expect(screen.getByText(/retry/i)).toBeInTheDocument() - }) - - it('should display error count in description', () => { - mockErrorDocsQuery.mockReturnValue( - createMockQueryResult( - { - total: 5, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, - false, - ), - ) - - render() - expect(screen.getByText(/5/)).toBeInTheDocument() - }) + it('waits for the requested dataset error documents', async () => { + const response = createDeferred() + getErrorDocs.mockReturnValue(response.promise) + const { container } = render() + expect(container.firstChild).toBeNull() + expect(getErrorDocs).toHaveBeenCalledWith({ params: { dataset_id: 'dataset-1' } }) + await act(async () => response.resolve(errors)) + expect(await screen.findByText(/retry/i)).toBeInTheDocument() + expect(screen.getByText(/2/)).toBeInTheDocument() }) - - describe('Props', () => { - it('should load the error documents of the given dataset', () => { - mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) - - render() - expect(mockErrorDocsQuery).toHaveBeenCalledWith( - expect.objectContaining({ - input: { params: { dataset_id: 'my-dataset-id' } }, - enabled: true, - }), - ) - }) + it('renders nothing when the response contains no failures', async () => { + getErrorDocs.mockResolvedValue({ total: 0, data: [] }) + const { container, queryClient } = render() + await waitFor(() => expect(queryClient.isFetching()).toBe(0)) + expect(container.firstChild).toBeNull() }) - - describe('User Interactions', () => { - it('should call retryErrorDocs when retry button is clicked', async () => { - mockErrorDocsQuery.mockReturnValue( - createMockQueryResult( - { - total: 2, - data: [{ id: 'doc1' }, { id: 'doc2' }] as ErrorDocsResponse['data'], - }, - false, - ), - ) - - mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) - - render() - - const retryButton = screen.getByText(/retry/i) - fireEvent.click(retryButton) - - await waitFor(() => { - expect(mockRetryErrorDocs).toHaveBeenCalledWith({ - datasetId: 'test-dataset', - document_ids: ['doc1', 'doc2'], - }) - }) - - // Wait for all state updates to complete - await waitFor(() => { - expect(mockRefetch).toHaveBeenCalled() - }) - }) - - it('should refetch error docs after successful retry', async () => { - mockErrorDocsQuery.mockReturnValue( - createMockQueryResult( - { - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, - false, - ), - ) - - mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) - - render() - - const retryButton = screen.getByText(/retry/i) - fireEvent.click(retryButton) - - await waitFor(() => { - expect(mockRefetch).toHaveBeenCalled() - }) - }) - - it('should disable button while retrying', async () => { - mockErrorDocsQuery.mockReturnValue( - createMockQueryResult( - { - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, - false, - ), - ) - - let resolveRetry: ((value: { result: 'success' }) => void) | undefined - mockRetryErrorDocs.mockImplementation( - () => - new Promise((resolve) => { - resolveRetry = resolve - }), - ) - - render() - - const retryButton = screen.getByText(/retry/i) - fireEvent.click(retryButton) - - // Button should show disabled styling during retry - await waitFor(() => { - const button = screen.getByText(/retry/i) - expect(button).toHaveClass('cursor-not-allowed') - expect(button).toHaveClass('text-text-disabled') - }) - - resolveRetry?.({ result: 'success' }) - await waitFor(() => { - expect(mockRefetch).toHaveBeenCalled() - }) - }) + it('retries the failed documents and refreshes their status', async () => { + getErrorDocs.mockResolvedValueOnce(errors).mockResolvedValue({ total: 0, data: [] }) + render() + fireEvent.click(await screen.findByText(/retry/i)) + await waitFor(() => + expect(retryErrorDocs).toHaveBeenCalledWith({ + datasetId: 'dataset-1', + document_ids: ['doc1', 'doc2'], + }), + ) + await waitFor(() => expect(getErrorDocs).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(screen.queryByText(/retry/i)).not.toBeInTheDocument()) }) - - describe('State Management', () => { - it('should transition to error state when retry fails', async () => { - mockErrorDocsQuery.mockReturnValue( - createMockQueryResult( - { - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, - false, - ), - ) - - mockRetryErrorDocs.mockRejectedValue(new Error('retry failed')) - - render() - - const retryButton = screen.getByText(/retry/i) - fireEvent.click(retryButton) - - // Wait for retry to complete and state to update - await waitFor(() => { - expect(mockRetryErrorDocs).toHaveBeenCalled() - }) - - // Button should still be visible after failed retry - await waitFor(() => { - expect(screen.getByText(/retry/i)).toBeInTheDocument() - }) - }) - - it('should transition to success state when total becomes 0', async () => { - const { rerender } = render() - - // Initially has errors - mockErrorDocsQuery.mockReturnValue( - createMockQueryResult( - { - total: 1, - data: [{ id: 'doc1' }] as ErrorDocsResponse['data'], - }, - false, - ), - ) - - rerender() - expect(screen.getByText(/retry/i)).toBeInTheDocument() - - // Now no errors - mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) - - rerender() - - await waitFor(() => { - expect(screen.queryByText(/retry/i)).not.toBeInTheDocument() - }) - }) + it('ignores repeated clicks while retrying', async () => { + const response = createDeferred<{ result: 'success' }>() + retryErrorDocs.mockReturnValue(response.promise) + render() + fireEvent.click(await screen.findByText(/retry/i)) + expect(screen.getByText(/retry/i)).toHaveClass('cursor-not-allowed') + fireEvent.click(screen.getByText(/retry/i)) + expect(retryErrorDocs).toHaveBeenCalledTimes(1) + await act(async () => response.resolve({ result: 'success' })) + await waitFor(() => expect(getErrorDocs).toHaveBeenCalledTimes(2)) }) - - describe('Edge Cases', () => { - it('should handle empty data array', () => { - mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 0, data: [] }, false)) - - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('should handle undefined data by showing error state', () => { - // When data is undefined but not loading, the component shows error state - // because errorDocs?.total is not strictly equal to 0 - mockErrorDocsQuery.mockReturnValue(createMockQueryResult(undefined, false)) - - render() - // Component renders with undefined count - expect(screen.getByText(/retry/i)).toBeInTheDocument() - }) - - it('should handle retry with empty document list', async () => { - mockErrorDocsQuery.mockReturnValue(createMockQueryResult({ total: 1, data: [] }, false)) - - mockRetryErrorDocs.mockResolvedValue({ result: 'success' }) - - render() - - const retryButton = screen.getByText(/retry/i) - fireEvent.click(retryButton) - - await waitFor(() => { - expect(mockRetryErrorDocs).toHaveBeenCalledWith({ - datasetId: 'test-dataset', - document_ids: [], - }) - }) - - // Wait for all state updates to complete - await waitFor(() => { - expect(mockRefetch).toHaveBeenCalled() - }) - }) + it('keeps retry available after failure without refreshing history', async () => { + retryErrorDocs.mockRejectedValueOnce(new Error('Retry failed')) + render() + fireEvent.click(await screen.findByText(/retry/i)) + await waitFor(() => expect(screen.getByText(/retry/i)).not.toHaveClass('cursor-not-allowed')) + expect(getErrorDocs).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByText(/retry/i)) + await waitFor(() => expect(retryErrorDocs).toHaveBeenCalledTimes(2)) + }) + it('shows retry when the status request fails', async () => { + getErrorDocs.mockRejectedValue(new Error('Unavailable')) + render() + expect(await screen.findByText(/retry/i)).toBeInTheDocument() + }) + it('does not fetch without a dataset ID', () => { + render() + expect(getErrorDocs).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx b/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx index f25b0a7d9e164c..74d662da5b0359 100644 --- a/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx +++ b/web/app/components/datasets/common/image-uploader/hooks/__tests__/use-upload.spec.tsx @@ -43,11 +43,10 @@ type FileUploadOptions = { } const mockFileUpload = vi.fn<(options: FileUploadOptions) => void>() -const mockGetFileUploadErrorMessage = vi.fn(() => 'Upload error') -vi.mock('@/app/components/base/file-uploader/utils', () => ({ +vi.mock('@/app/components/base/file-uploader/utils', async (importOriginal) => ({ + ...(await importOriginal()), fileUpload: (options: FileUploadOptions) => mockFileUpload(options), - getFileUploadErrorMessage: () => mockGetFileUploadErrorMessage(), })) const createWrapper = () => { @@ -377,7 +376,9 @@ describe('useUpload hook', () => { }) await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Upload error') + expect(toast.error).toHaveBeenCalledWith( + 'common.fileUploader.uploadFromComputerUploadError', + ) }) }) }) @@ -439,7 +440,9 @@ describe('useUpload hook', () => { }) await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Upload error') + expect(toast.error).toHaveBeenCalledWith( + 'common.fileUploader.uploadFromComputerUploadError', + ) }) }) }) diff --git a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx index 0d1ec5fb9a492c..2a9016dbdeacba 100644 --- a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import RetrievalParamConfig from '../index' @@ -51,67 +52,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec ), })) -vi.mock('@/app/components/app/configuration/dataset-config/params-config/weighted-score', () => ({ - default: ({ - value, - onChange, - }: { - value: { value: number[] } - onChange: (v: { value: number[] }) => void - }) => ( -
- -
- ), -})) - -vi.mock('@/app/components/base/param-item/top-k-item', () => ({ - default: ({ value, onChange }: { value: number; onChange: (key: string, v: number) => void }) => ( -
- -
- ), -})) - -vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ - default: ({ - value, - onChange, - enable, - hasSwitch, - onSwitchChange, - }: { - value: number - onChange: (key: string, v: number) => void - enable: boolean - hasSwitch: boolean - onSwitchChange?: (key: string, v: boolean) => void - }) => ( -
- - {hasSwitch && onSwitchChange && ( - - )} -
- ), -})) - describe('RetrievalParamConfig', () => { const createDefaultConfig = (overrides?: Partial): RetrievalConfig => ({ search_method: RETRIEVE_METHOD.semantic, @@ -186,8 +126,12 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByTestId('top-k-item'))!.toBeInTheDocument() - expect(screen.getByTestId('top-k-item'))!.toHaveAttribute('data-value', '5') + expect( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }), + )!.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }))!.toHaveValue( + '5', + ) }) it('should render score threshold item when reranking is enabled', () => { @@ -200,7 +144,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByTestId('score-threshold-item'))!.toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + )!.toBeInTheDocument() }) it('should toggle reranking enable', () => { @@ -262,7 +208,8 @@ describe('RetrievalParamConfig', () => { }) }) - it('should update top_k value', () => { + it('should update top_k value', async () => { + const user = userEvent.setup() const config = createDefaultConfig() render( { />, ) - fireEvent.click(screen.getByTestId('change-top-k-btn')) + await user.clear(screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' })) + await user.type(screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }), '10') expect(mockOnChange).toHaveBeenCalledWith({ ...config, @@ -280,7 +228,8 @@ describe('RetrievalParamConfig', () => { }) }) - it('should update score threshold value', () => { + it('should update score threshold value', async () => { + const user = userEvent.setup() const config = createDefaultConfig({ reranking_enable: true }) render( { />, ) - fireEvent.click(screen.getByTestId('change-score-btn')) + await user.clear( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + ) + await user.type( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + '0.8', + ) expect(mockOnChange).toHaveBeenCalledWith({ ...config, @@ -308,7 +263,9 @@ describe('RetrievalParamConfig', () => { />, ) - fireEvent.click(screen.getByTestId('toggle-score-switch-btn')) + fireEvent.click( + screen.getByRole('switch', { name: 'appDebug.datasetConfig.score_threshold' }), + ) expect(mockOnChange).toHaveBeenCalledWith({ ...config, @@ -378,7 +335,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.queryByTestId('score-threshold-item')).not.toBeInTheDocument() + expect( + screen.queryByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + ).not.toBeInTheDocument() }) it('should show score threshold when reranking is enabled for full text search', () => { @@ -394,7 +353,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByTestId('score-threshold-item'))!.toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + )!.toBeInTheDocument() }) }) @@ -409,7 +370,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.queryByRole('switch')).not.toBeInTheDocument() + expect( + screen.queryByRole('switch', { name: 'modelProvider.modelProvider.rerankModel.key' }), + ).not.toBeInTheDocument() }) it('should not render model selector for keyword search', () => { @@ -435,7 +398,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByTestId('top-k-item'))!.toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }), + )!.toBeInTheDocument() }) it('should not render score threshold for keyword search', () => { @@ -448,7 +413,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.queryByTestId('score-threshold-item')).not.toBeInTheDocument() + expect( + screen.queryByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + ).not.toBeInTheDocument() }) }) @@ -530,7 +497,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByTestId('weighted-score'))!.toBeInTheDocument() + expect( + screen.getByRole('slider', { name: 'dataset.weightedScore.semantic' }), + )!.toBeInTheDocument() expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() }) @@ -599,7 +568,8 @@ describe('RetrievalParamConfig', () => { expect(mockNotify).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired') }) - it('should update weights when WeightedScore changes', () => { + it('should update weights when WeightedScore changes', async () => { + const user = userEvent.setup() const weightedConfig = createDefaultConfig({ search_method: RETRIEVE_METHOD.hybrid, reranking_mode: RerankingModeEnum.WeightedScore, @@ -623,7 +593,8 @@ describe('RetrievalParamConfig', () => { />, ) - fireEvent.click(screen.getByTestId('change-weights-btn')) + screen.getByRole('slider', { name: 'dataset.weightedScore.semantic' }).focus() + await user.keyboard('{ArrowLeft}') expect(mockOnChange).toHaveBeenCalled() const calledWith = mockOnChange.mock.calls[0]![0] @@ -640,11 +611,16 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByTestId('top-k-item'))!.toBeInTheDocument() - expect(screen.getByTestId('score-threshold-item'))!.toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }), + )!.toBeInTheDocument() + expect( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + )!.toBeInTheDocument() }) - it('should update top_k for hybrid search', () => { + it('should update top_k for hybrid search', async () => { + const user = userEvent.setup() render( { />, ) - fireEvent.click(screen.getByTestId('change-top-k-btn')) + await user.clear(screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' })) + await user.type(screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }), '10') expect(mockOnChange).toHaveBeenCalledWith({ ...hybridConfig, @@ -661,7 +638,8 @@ describe('RetrievalParamConfig', () => { }) }) - it('should update score threshold for hybrid search', () => { + it('should update score threshold for hybrid search', async () => { + const user = userEvent.setup() render( { />, ) - fireEvent.click(screen.getByTestId('change-score-btn')) + await user.clear( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + ) + await user.type( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + '0.8', + ) expect(mockOnChange).toHaveBeenCalledWith({ ...hybridConfig, @@ -687,7 +671,9 @@ describe('RetrievalParamConfig', () => { />, ) - fireEvent.click(screen.getByTestId('toggle-score-switch-btn')) + fireEvent.click( + screen.getByRole('switch', { name: 'appDebug.datasetConfig.score_threshold' }), + ) expect(mockOnChange).toHaveBeenCalledWith({ ...hybridConfig, @@ -749,7 +735,9 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.queryByRole('switch')).not.toBeInTheDocument() + expect( + screen.queryByRole('switch', { name: 'modelProvider.modelProvider.rerankModel.key' }), + ).not.toBeInTheDocument() }) it('should update model selection for hybrid search', () => { diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx index 03f13662611be9..5b94c42cdce1d9 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx @@ -15,16 +15,16 @@ vi.mock('@/next/navigation', () => ({ const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.imports.post': () => ({ - mutateAsync: mockImportDSL, - }), - 'rag.pipelines.imports.byImportId.confirm.post': () => ({ - mutateAsync: mockImportDSLConfirm, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.imports.post': (input) => mockImportDSL(input), + 'rag.pipelines.imports.byImportId.confirm.post': (input) => mockImportDSLConfirm(input), }), - }) + } }) // Mock plugin dependencies hook @@ -67,15 +67,6 @@ vi.mock('@/app/notifications', () => ({ toast: toastMocks.api, })) -vi.mock('use-context-selector', async () => { - const actual = - await vi.importActual('use-context-selector') - return { - ...actual, - useContext: vi.fn(() => ({ notify: toastMocks.api })), - } -}) - const createMockFile = (name = 'test.pipeline'): File => { return new File(['test content'], name, { type: 'application/octet-stream' }) } diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx index 828ad7ec931331..311929227338ca 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx @@ -15,16 +15,16 @@ vi.mock('@/next/navigation', () => ({ const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.imports.post': () => ({ - mutateAsync: mockImportDSL, - }), - 'rag.pipelines.imports.byImportId.confirm.post': () => ({ - mutateAsync: mockImportDSLConfirm, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.imports.post': (input) => mockImportDSL(input), + 'rag.pipelines.imports.byImportId.confirm.post': (input) => mockImportDSLConfirm(input), }), - }) + } }) // Mock plugin dependencies hook @@ -67,15 +67,6 @@ vi.mock('@/app/notifications', () => ({ toast: toastMocks.api, })) -vi.mock('use-context-selector', async () => { - const actual = - await vi.importActual('use-context-selector') - return { - ...actual, - useContext: vi.fn(() => ({ notify: toastMocks.api })), - } -}) - const createImportDSLResponse = (overrides = {}) => ({ id: 'import-123', status: 'completed' as const, diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx index 2f18005ce3e0c4..323d4ac1642c74 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx @@ -32,11 +32,15 @@ vi.mock('@/app/notifications', async (importOriginal) => { const mockCreateEmptyDataset = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipeline.emptyDataset.post': () => ({ mutateAsync: mockCreateEmptyDataset }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipeline.emptyDataset.post': (input) => mockCreateEmptyDataset(input), + }), + } }) // CreateCard Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx index 433bc61689fcc7..718d503d49b9e5 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx @@ -18,13 +18,15 @@ const render = (ui: ReactElement<{ onClose: () => void }>) => const mockUpdatePipeline = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipeline.customized.templates.byTemplateId.patch': () => ({ - mutateAsync: mockUpdatePipeline, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipeline.customized.templates.byTemplateId.patch': (input) => mockUpdatePipeline(input), }), - }) + } }) const { mockToastError } = vi.hoisted(() => ({ @@ -168,10 +170,7 @@ describe('EditPipelineInfo', () => { it('should submit with Enter from the name input', async () => { const user = userEvent.setup() - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) render() @@ -184,10 +183,7 @@ describe('EditPipelineInfo', () => { }) it('should invalidate template list on successful save', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) render() @@ -202,10 +198,7 @@ describe('EditPipelineInfo', () => { }) it('should call onClose on successful save', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) render() @@ -292,10 +285,7 @@ describe('EditPipelineInfo', () => { }) it('should save correct icon_info when starting with image icon type', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) const props = { ...defaultProps, @@ -316,16 +306,12 @@ describe('EditPipelineInfo', () => { }), }), }), - expect.any(Object), ) }) }) it('should save correct icon_info when starting with emoji icon type', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) render() @@ -342,7 +328,6 @@ describe('EditPipelineInfo', () => { }), }), }), - expect.any(Object), ) }) }) @@ -367,10 +352,7 @@ describe('EditPipelineInfo', () => { }) it('should switch from image icon to emoji icon when selected', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) const props = { ...defaultProps, @@ -401,7 +383,6 @@ describe('EditPipelineInfo', () => { }), }), }), - expect.any(Object), ) }) }) @@ -471,10 +452,7 @@ describe('EditPipelineInfo', () => { }) it('should save with new emoji icon selection', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) render() @@ -497,16 +475,12 @@ describe('EditPipelineInfo', () => { }), }), }), - expect.any(Object), ) }) }) it('should save after confirming a real emoji selection from an image icon', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) render() @@ -532,7 +506,6 @@ describe('EditPipelineInfo', () => { }), }), }), - expect.any(Object), ) }) }) @@ -541,10 +514,7 @@ describe('EditPipelineInfo', () => { // Save Request Tests describe('Save Request', () => { it('should send correct request with emoji icon', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) render() @@ -563,16 +533,12 @@ describe('EditPipelineInfo', () => { }), }), }), - expect.any(Object), ) }) }) it('should send correct request with image icon', async () => { - mockUpdatePipeline.mockImplementation((_data, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockUpdatePipeline.mockResolvedValue(undefined) const props = { ...defaultProps, @@ -593,7 +559,6 @@ describe('EditPipelineInfo', () => { }), }), }), - expect.any(Object), ) }) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index 55ea2e0882da0a..29463e091d25e2 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -1,11 +1,12 @@ import type { PipelineTemplate } from '@/models/pipeline' import { DialogTrigger } from '@langgenius/dify-ui/dialog' import { QueryClient } from '@tanstack/react-query' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ChunkingMode } from '@/models/datasets' import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import TemplateCard from '../index' const invalidateQueries = vi.spyOn(QueryClient.prototype, 'invalidateQueries') @@ -112,33 +113,6 @@ const mockExportPipelineDSL = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() // Configurable isPending for export -let mockIsExporting = false - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipeline.dataset.post': () => ({ mutateAsync: mockCreateDataset }), - 'rag.pipeline.templates.byTemplateId.get': () => ({ - data: { - name: 'Test Pipeline', - description: 'Test pipeline description', - icon_info: { icon_type: 'emoji', icon: '📊', icon_background: '#FFF4ED', icon_url: '' }, - chunk_structure: 'text', - graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, - }, - refetch: mockGetPipelineTemplateInfo, - }), - 'rag.pipeline.customized.templates.byTemplateId.delete': () => ({ - mutateAsync: mockDeletePipeline, - }), - 'rag.pipeline.customized.templates.byTemplateId.post': () => ({ - mutateAsync: mockExportPipelineDSL, - get isPending() { - return mockIsExporting - }, - }), - }) -}) // Mock plugin dependencies hook vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ @@ -181,15 +155,18 @@ describe('TemplateCard', () => { vi.clearAllMocks() mockToastSuccess.mockReset() mockToastError.mockReset() - mockIsExporting = false _capturedHandleDelete = undefined _capturedHandleExportDSL = undefined _capturedOpenEditModal = undefined mockHandleCheckPluginDependencies.mockResolvedValue(undefined) mockGetPipelineTemplateInfo.mockResolvedValue({ - data: { - export_data: 'yaml_content_here', - }, + id: 'pipeline-1', + name: 'Test Pipeline', + description: 'Test pipeline description', + export_data: 'yaml_content_here', + icon_info: {}, + chunk_structure: 'text', + graph: {}, }) }) @@ -221,7 +198,7 @@ describe('TemplateCard', () => { // Use Template Flow Tests describe('Use Template Flow', () => { it('should show error when template info fetch fails', async () => { - mockGetPipelineTemplateInfo.mockResolvedValue({ data: null }) + mockGetPipelineTemplateInfo.mockRejectedValue(new Error('Template unavailable')) render() const chooseButton = screen.getByTestId('action-choose') @@ -233,9 +210,9 @@ describe('TemplateCard', () => { }) it('should create dataset when template is applied', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' }) - return Promise.resolve() + mockCreateDataset.mockResolvedValue({ + dataset_id: 'new-dataset-123', + pipeline_id: 'pipe-123', }) render() @@ -248,9 +225,9 @@ describe('TemplateCard', () => { }) it('should navigate to pipeline page on successful creation', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' }) - return Promise.resolve() + mockCreateDataset.mockResolvedValue({ + dataset_id: 'new-dataset-123', + pipeline_id: 'pipe-123', }) render() @@ -263,9 +240,9 @@ describe('TemplateCard', () => { }) it('should invalidate dataset list on successful creation', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' }) - return Promise.resolve() + mockCreateDataset.mockResolvedValue({ + dataset_id: 'new-dataset-123', + pipeline_id: 'pipe-123', }) render() @@ -278,9 +255,9 @@ describe('TemplateCard', () => { }) it('should show success toast on successful creation', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' }) - return Promise.resolve() + mockCreateDataset.mockResolvedValue({ + dataset_id: 'new-dataset-123', + pipeline_id: 'pipe-123', }) render() @@ -293,10 +270,7 @@ describe('TemplateCard', () => { }) it('should show error toast on creation failure', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onError(new Error('Creation failed')) - return Promise.resolve() - }) + mockCreateDataset.mockRejectedValue(new Error('Creation failed')) render() const chooseButton = screen.getByTestId('action-choose') @@ -353,9 +327,9 @@ describe('TemplateCard', () => { }) it('should trigger use template from details modal', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' }) - return Promise.resolve() + mockCreateDataset.mockResolvedValue({ + dataset_id: 'new-dataset-123', + pipeline_id: 'pipe-123', }) render() @@ -366,7 +340,7 @@ describe('TemplateCard', () => { expect(screen.getByRole('dialog', { name: 'Test Pipeline' })).toBeInTheDocument() }) - const applyButton = screen.getByRole('button', { + const applyButton = await screen.findByRole('button', { name: 'datasetPipeline.operations.useTemplate', }) fireEvent.click(applyButton) @@ -380,9 +354,9 @@ describe('TemplateCard', () => { // Pipeline ID Branch Tests describe('Pipeline ID Branch', () => { it('should call handleCheckPluginDependencies when pipeline_id is present', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' }) - return Promise.resolve() + mockCreateDataset.mockResolvedValue({ + dataset_id: 'new-dataset-123', + pipeline_id: 'pipe-123', }) render() @@ -395,10 +369,7 @@ describe('TemplateCard', () => { }) it('should not call handleCheckPluginDependencies when pipeline_id is falsy', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: '' }) - return Promise.resolve() - }) + mockCreateDataset.mockResolvedValue({ dataset_id: 'new-dataset-123', pipeline_id: '' }) render() const chooseButton = screen.getByTestId('action-choose') @@ -411,10 +382,7 @@ describe('TemplateCard', () => { }) it('should not call handleCheckPluginDependencies when pipeline_id is null', async () => { - mockCreateDataset.mockImplementation((_data, callbacks) => { - callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: null }) - return Promise.resolve() - }) + mockCreateDataset.mockResolvedValue({ dataset_id: 'new-dataset-123', pipeline_id: null }) render() const chooseButton = screen.getByTestId('action-choose') @@ -429,40 +397,35 @@ describe('TemplateCard', () => { // Export DSL Tests (Branch Coverage) describe('Export DSL', () => { - it('should not export when already exporting', async () => { - mockIsExporting = true - + it('does not start a duplicate export while the first is pending', async () => { + const response = createDeferred<{ data: string }>() + mockExportPipelineDSL.mockReturnValue(response.promise) render() - const exportButton = screen.getByTestId('action-export') - fireEvent.click(exportButton) - - // Export should not be called due to isExporting check - expect(mockExportPipelineDSL).not.toHaveBeenCalled() + fireEvent.click(screen.getByTestId('action-export')) + await waitFor(() => expect(mockExportPipelineDSL).toHaveBeenCalledTimes(1)) + await act(async () => {}) + fireEvent.click(screen.getByTestId('action-export')) + expect(mockExportPipelineDSL).toHaveBeenCalledTimes(1) + await act(async () => response.resolve({ data: 'yaml_content' })) + await waitFor(() => expect(mockToastSuccess).toHaveBeenCalled()) }) it('should call exportPipelineDSL on export action', async () => { - mockExportPipelineDSL.mockImplementation((_id, callbacks) => { - callbacks.onSuccess({ data: 'yaml_content' }) - return Promise.resolve() - }) + mockExportPipelineDSL.mockResolvedValue({ data: 'yaml_content' }) render() const exportButton = screen.getByTestId('action-export') fireEvent.click(exportButton) await waitFor(() => { - expect(mockExportPipelineDSL).toHaveBeenCalledWith( - { params: { template_id: 'pipeline-1' } }, - expect.any(Object), - ) + expect(mockExportPipelineDSL).toHaveBeenCalledWith({ + params: { template_id: 'pipeline-1' }, + }) }) }) it('should show success toast on export success', async () => { - mockExportPipelineDSL.mockImplementation((_id, callbacks) => { - callbacks.onSuccess({ data: 'yaml_content' }) - return Promise.resolve() - }) + mockExportPipelineDSL.mockResolvedValue({ data: 'yaml_content' }) render() const exportButton = screen.getByTestId('action-export') @@ -474,10 +437,7 @@ describe('TemplateCard', () => { }) it('should show error toast on export failure', async () => { - mockExportPipelineDSL.mockImplementation((_id, callbacks) => { - callbacks.onError(new Error('Export failed')) - return Promise.resolve() - }) + mockExportPipelineDSL.mockRejectedValue(new Error('Export failed')) render() const exportButton = screen.getByTestId('action-export') @@ -490,10 +450,7 @@ describe('TemplateCard', () => { it('should call downloadBlob on successful export', async () => { const { downloadBlob } = await import('@/utils/download') - mockExportPipelineDSL.mockImplementation((_id, callbacks) => { - callbacks.onSuccess({ data: 'yaml_content' }) - return Promise.resolve() - }) + mockExportPipelineDSL.mockResolvedValue({ data: 'yaml_content' }) render() const exportButton = screen.getByTestId('action-export') @@ -538,10 +495,7 @@ describe('TemplateCard', () => { }) it('should call deletePipeline when confirm is clicked (onConfirmDelete)', async () => { - mockDeletePipeline.mockImplementation((_id, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockDeletePipeline.mockResolvedValue({ result: 'success' }) render() const deleteButton = screen.getByTestId('action-delete') @@ -554,18 +508,12 @@ describe('TemplateCard', () => { fireEvent.click(getDeleteConfirmButton()) await waitFor(() => { - expect(mockDeletePipeline).toHaveBeenCalledWith( - { params: { template_id: 'pipeline-1' } }, - expect.any(Object), - ) + expect(mockDeletePipeline).toHaveBeenCalledWith({ params: { template_id: 'pipeline-1' } }) }) }) it('should invalidate template list on successful delete', async () => { - mockDeletePipeline.mockImplementation((_id, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockDeletePipeline.mockResolvedValue({ result: 'success' }) render() const deleteButton = screen.getByTestId('action-delete') @@ -585,10 +533,7 @@ describe('TemplateCard', () => { }) it('should close confirm dialog after successful delete', async () => { - mockDeletePipeline.mockImplementation((_id, callbacks) => { - callbacks.onSuccess() - return Promise.resolve() - }) + mockDeletePipeline.mockResolvedValue({ result: 'success' }) render() const deleteButton = screen.getByTestId('action-delete') @@ -698,3 +643,18 @@ describe('TemplateCard', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipeline.dataset.post': (input) => mockCreateDataset(input), + 'rag.pipeline.templates.byTemplateId.get': (input) => mockGetPipelineTemplateInfo(input), + 'rag.pipeline.customized.templates.byTemplateId.delete': (input) => mockDeletePipeline(input), + 'rag.pipeline.customized.templates.byTemplateId.post': (input) => + mockExportPipelineDSL(input), + }), + } +}) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx index d8eb093982456e..4f1a30a403d7a0 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx @@ -46,7 +46,7 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar select: selectPipelineTemplate, }), ) - const { mutateAsync: createDataset } = useMutation( + const { mutate: createDataset } = useMutation( consoleQuery.rag.pipeline.dataset.post.mutationOptions(), ) const { handleCheckPluginDependencies } = usePluginDependencies() @@ -63,7 +63,7 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar const request = { yaml_content: pipelineTemplateInfo.export_data, } - await createDataset( + createDataset( { body: request }, { onSuccess: async (newDataset) => { @@ -103,13 +103,13 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar setShowEditModal(false) }, []) - const { mutateAsync: exportPipelineDSL, isPending: isExporting } = useMutation( + const { mutate: exportPipelineDSL, isPending: isExporting } = useMutation( consoleQuery.rag.pipeline.customized.templates.byTemplateId.post.mutationOptions(), ) - const handleExportDSL = useCallback(async () => { + const handleExportDSL = useCallback(() => { if (isExporting) return - await exportPipelineDSL( + exportPipelineDSL( { params: { template_id: pipeline.id } }, { onSuccess: (res) => { @@ -132,12 +132,12 @@ const TemplateCard = ({ pipeline, showMoreOperations = true, type }: TemplateCar setShowConfirmDelete(false) }, []) - const { mutateAsync: deletePipeline } = useMutation( + const { mutate: deletePipeline } = useMutation( consoleQuery.rag.pipeline.customized.templates.byTemplateId.delete.mutationOptions(), ) - const onConfirmDelete = useCallback(async () => { - await deletePipeline( + const onConfirmDelete = useCallback(() => { + deletePipeline( { params: { template_id: pipeline.id } }, { onSuccess: () => { diff --git a/web/app/components/datasets/create/__tests__/index.spec.tsx b/web/app/components/datasets/create/__tests__/index.spec.tsx index 8c79c87f441d96..3b7b24859adde4 100644 --- a/web/app/components/datasets/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/__tests__/index.spec.tsx @@ -13,9 +13,12 @@ import * as React from 'react' import { DataSourceProvider } from '@/models/common' import { ChunkingMode, DataSourceType } from '@/models/datasets' import { PermissionLevel } from '@/models/permission' +import { consoleQuery } from '@/service/console' import { createAccountProfileQueryClient } from '@/test/console/account-profile' -import { render as renderWithConsoleState } from '@/test/console/render' +import { render as renderWithConsoleStateWithoutUrlState } from '@/test/console/render' +import { createDeferred } from '@/test/deferred' import { createDatasetFixture } from '@/test/fixtures/dataset' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { RETRIEVE_METHOD } from '@/types/app' import DatasetUpdateForm from '../index' @@ -48,8 +51,22 @@ let mockCurrentUserId = 'user-1' let mockWorkspacePermissionKeys = ['dataset.create_and_management'] let mockIsLoadingWorkspacePermissionKeys = false +const onUrlUpdate = vi.fn() +const renderWithConsoleState = createNuqsTestRenderer( + renderWithConsoleStateWithoutUrlState, + () => ({ onUrlUpdate }), +) + +let mockDataSourceList: { result: DatasourceProviderAuthResponse[] } | undefined +let seedDatasource = true + const render = (ui: Parameters[0]) => { const queryClient = createAccountProfileQueryClient({ id: mockCurrentUserId }) + if (seedDatasource) + queryClient.setQueryData<{ result: DatasourceProviderAuthResponse[] }>( + consoleQuery.auth.plugin.datasource.defaultList.get.queryKey(), + mockDataSourceList, + ) return renderWithConsoleState(ui, { wrapper: ({ children }: { children: React.ReactNode }) => ( {children} @@ -85,12 +102,6 @@ vi.mock('@/features/system-features/state', async () => { })) }) -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - // Mock dataset detail context let mockDatasetDetail: DatasetDetailWithPartialMembersResponse | undefined vi.mock('@/context/dataset-detail', () => ({ @@ -115,19 +126,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () })) // Mock useGetDefaultDataSourceListAuth hook -let mockDataSourceList: { result: DatasourceProviderAuthResponse[] } | undefined -let mockIsLoadingDataSourceList = false -let mockFetchingError = false -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'auth.plugin.datasource.defaultList.get': () => ({ - data: mockDataSourceList, - isLoading: mockIsLoadingDataSourceList, - isError: mockFetchingError, - }), - }) -}) +const getDatasourceList = vi.fn() // Track props passed to child components let stepOneProps = {} as StepOneProps @@ -371,8 +370,8 @@ describe('DatasetUpdateForm', () => { mockDatasetDetail = undefined mockEmbeddingsDefaultModel = { model: 'text-embedding-ada-002', provider: 'openai' } mockDataSourceList = { result: [createMockDataSourceAuth()] } - mockIsLoadingDataSourceList = false - mockFetchingError = false + seedDatasource = true + getDatasourceList.mockImplementation(async () => mockDataSourceList) mockCurrentUserId = 'user-1' mockWorkspacePermissionKeys = ['dataset.create_and_management'] mockIsLoadingWorkspacePermissionKeys = false @@ -400,51 +399,22 @@ describe('DatasetUpdateForm', () => { }) it('should show loading state when data source list is loading', () => { - mockIsLoadingDataSourceList = true + seedDatasource = false + getDatasourceList.mockReturnValue(new Promise(() => {})) render() - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) - // Assert - Loading component should be rendered (not the steps) // Assert - Loading component should be rendered (not the steps) expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() }) - it('should show error state when fetching fails', () => { - mockFetchingError = true + it('should show error state when fetching fails', async () => { + seedDatasource = false + getDatasourceList.mockRejectedValue(new Error('Datasource unavailable')) render() - expect(screen.getByText('datasetCreation.error.unavailable'))!.toBeInTheDocument() + expect(await screen.findByText('datasetCreation.error.unavailable'))!.toBeInTheDocument() }) }) @@ -641,21 +611,25 @@ describe('DatasetUpdateForm', () => { // User Interactions - Test event handlers describe('User Interactions', () => { - it('should open account settings when onSetting is called from StepOne', () => { + it('should open account settings when onSetting is called from StepOne', async () => { render() fireEvent.click(screen.getByTestId('step-one-setting')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) - it('should open provider settings when onSetting is called from StepTwo', () => { + it('should open provider settings when onSetting is called from StepTwo', async () => { render() fireEvent.click(screen.getByTestId('step-one-next')) fireEvent.click(screen.getByTestId('step-two-setting')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'), + ) }) it('should update crawl options when onCrawlOptionsChange is called', () => { @@ -790,37 +764,6 @@ describe('DatasetUpdateForm', () => { fireEvent.click(screen.getByTestId('step-one-next')) - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition - // Assert - StepTwo should not render due to condition // Assert - StepTwo should not render due to condition expect(screen.queryByTestId('step-two')).not.toBeInTheDocument() }) @@ -912,7 +855,6 @@ describe('DatasetUpdateForm', () => { // Act - Navigate to step 3 fireEvent.click(screen.getByTestId('step-two-next')) - // Assert - Should use datasetDetail value, not cache // Assert - Should use datasetDetail value, not cache expect(screen.getByTestId('step-three-indexing-type'))!.toHaveTextContent('economy') }) @@ -945,14 +887,6 @@ describe('DatasetUpdateForm', () => { expect(stepOneProps.authedDataSourceList).toEqual([mockAuth]) }) - it('should pass empty array when dataSourceList is undefined', () => { - mockDataSourceList = undefined - - render() - - expect(stepOneProps.authedDataSourceList).toEqual([]) - }) - it('should pass dataSourceTypeDisable as true when datasetDetail has data_source_type', () => { mockDatasetDetail = createMockDataset({ data_source_type: DataSourceType.FILE }) @@ -1004,7 +938,6 @@ describe('DatasetUpdateForm', () => { fireEvent.click(screen.getByTestId('step-two-update-retrieval-cache')) fireEvent.click(screen.getByTestId('step-two-next')) - // Assert - Should use cached value // Assert - Should use cached value expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent( 'semantic_search', @@ -1061,7 +994,6 @@ describe('DatasetUpdateForm', () => { fireEvent.click(screen.getByTestId('step-one-next')) fireEvent.click(screen.getByTestId('step-two-prev')) - // Assert - All state should be preserved // Assert - All state should be preserved expect(screen.getByTestId('step-one-data-source-type'))!.toHaveTextContent( DataSourceType.NOTION, @@ -1086,7 +1018,6 @@ describe('DatasetUpdateForm', () => { fireEvent.click(screen.getByTestId('step-two-update-result-cache')) fireEvent.click(screen.getByTestId('step-two-next')) - // Assert - All data flows through to Step 3 // Assert - All data flows through to Step 3 expect(screen.getByTestId('step-three-indexing-type'))!.toHaveTextContent('high_quality') expect(screen.getByTestId('step-three-retrieval-method'))!.toHaveTextContent( @@ -1150,7 +1081,6 @@ describe('DatasetUpdateForm', () => { // Navigate to Step 3 fireEvent.click(screen.getByTestId('step-two-next')) - // Assert - Step 3 should show dataset details // Assert - Step 3 should show dataset details expect(screen.getByTestId('step-three-dataset-name'))!.toHaveTextContent('Existing Dataset') expect(screen.getByTestId('step-three-indexing-type'))!.toHaveTextContent('high_quality') @@ -1190,33 +1120,36 @@ describe('DatasetUpdateForm', () => { // Error State Tests describe('Error States', () => { - it('should display error message when fetching data source list fails', () => { - mockFetchingError = true + it('should display error message when fetching data source list fails', async () => { + seedDatasource = false + getDatasourceList.mockRejectedValue(new Error('Datasource unavailable')) render() - const errorElement = screen.getByText('datasetCreation.error.unavailable') + const errorElement = await screen.findByText('datasetCreation.error.unavailable') expect(errorElement)!.toBeInTheDocument() }) - it('should not render steps when in error state', () => { - mockFetchingError = true + it('should not render steps when in error state', async () => { + seedDatasource = false + getDatasourceList.mockRejectedValue(new Error('Datasource unavailable')) render() + await screen.findByText('datasetCreation.error.unavailable') expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() expect(screen.queryByTestId('step-two')).not.toBeInTheDocument() expect(screen.queryByTestId('step-three')).not.toBeInTheDocument() }) - it('should render error page with 500 code when in error state', () => { - mockFetchingError = true + it('should render error page with 500 code when in error state', async () => { + seedDatasource = false + getDatasourceList.mockRejectedValue(new Error('Datasource unavailable')) render() // Assert - Error state renders AppUnavailable, not the normal layout - // Assert - Error state renders AppUnavailable, not the normal layout - expect(screen.getByText('500'))!.toBeInTheDocument() + expect(await screen.findByText('500'))!.toBeInTheDocument() expect(screen.queryByTestId('top-bar')).not.toBeInTheDocument() }) }) @@ -1224,7 +1157,8 @@ describe('DatasetUpdateForm', () => { // Loading State Tests describe('Loading States', () => { it('should not render steps while loading', () => { - mockIsLoadingDataSourceList = true + seedDatasource = false + getDatasourceList.mockReturnValue(new Promise(() => {})) render() @@ -1232,7 +1166,8 @@ describe('DatasetUpdateForm', () => { }) it('should render TopBar while loading', () => { - mockIsLoadingDataSourceList = true + seedDatasource = false + getDatasourceList.mockReturnValue(new Promise(() => {})) render() @@ -1240,46 +1175,16 @@ describe('DatasetUpdateForm', () => { }) it('should render StepOne after loading completes', async () => { - mockIsLoadingDataSourceList = true - const { rerender } = render() + seedDatasource = false + const response = createDeferred<{ result: DatasourceProviderAuthResponse[] }>() + getDatasourceList.mockReturnValue(response.promise) + render() - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered - // Assert - Initially not rendered // Assert - Initially not rendered expect(screen.queryByTestId('step-one')).not.toBeInTheDocument() // Act - Loading completes - mockIsLoadingDataSourceList = false - rerender() + response.resolve({ result: [createMockDataSourceAuth()] }) // Assert - Now rendered await waitFor(() => { @@ -1288,3 +1193,14 @@ describe('DatasetUpdateForm', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'auth.plugin.datasource.defaultList.get': () => getDatasourceList(), + }), + } +}) diff --git a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx index 95b333c1ffac0c..a91a6bb2d03773 100644 --- a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx @@ -18,44 +18,20 @@ const render = (ui: React.ReactElement) => { }) } -const mockNotify = vi.fn() -vi.mock('use-context-selector', async () => { - const actual = - await vi.importActual('use-context-selector') - return { - ...actual, - useContext: vi.fn(() => ({ notify: mockNotify })), - } -}) - // Mock services vi.mock('@/service/base', () => ({ upload: vi.fn().mockResolvedValue({ id: 'uploaded-id' }), })) -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 }, - }), -})) vi.mock('@/i18n/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], })) -vi.mock('@/app/components/base/file-uploader/utils', () => ({ - getFileUploadErrorMessage: () => 'Upload error', -})) - // Mock theme vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), })) -vi.mock('@/types/app', async (importOriginal) => ({ - ...(await importOriginal()), - Theme: { dark: 'dark', light: 'light' }, -})) - // Mock DocumentFileIcon - uses relative path from file-list-item.tsx vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ default: ({ extension }: { extension: string }) => ( diff --git a/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx index bf777765e3b781..d69cfe6f6e336c 100644 --- a/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx @@ -13,9 +13,6 @@ vi.mock('@/hooks/use-theme', () => ({ })) // Mock theme types -vi.mock('@/types/app', () => ({ - Theme: { dark: 'dark', light: 'light' }, -})) // Mock SimplePieChart with dynamic import handling vi.mock('next/dynamic', () => ({ diff --git a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx index c05053fb3da6b9..ed1cb8317a01f5 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx @@ -8,6 +8,7 @@ import { createConsoleQueryWrapper, renderWithConsoleQuery, } from '@/test/console/query-data' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' // Import after mocks import { useFileUpload } from '../use-file-upload' @@ -30,12 +31,12 @@ vi.mock('@/service/base', () => ({ })) // Mock file upload config -const mockFileUploadConfig = { +const fileUploadConfig = createFileUploadConfigFixture({ file_size_limit: 15, knowledge_file_size_limit: 50, batch_count_limit: 5, file_upload_limit: 10, -} +}) const supportedFormats = { allowed_extensions: ['pdf', 'docx', 'txt', 'md'], @@ -46,12 +47,10 @@ vi.mock('@/i18n/language', () => ({ })) // Mock file upload error message -vi.mock('@/app/components/base/file-uploader/utils', () => ({ - getFileUploadErrorMessage: (_e: unknown, defaultMsg: string) => defaultMsg, -})) const createQueryClient = () => { const queryClient = createConsoleQueryClient() + queryClient.setQueryData(consoleQuery.files.upload.get.queryKey(), fileUploadConfig) queryClient.setQueryData( consoleQuery.files.supportType.get.queryOptions().queryKey, supportedFormats, @@ -69,13 +68,6 @@ const render = (ui: ReactElement) => systemFeatures: { deployment_edition: 'CLOUD' }, }) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'files.upload.get': () => ({ data: mockFileUploadConfig }), - }) -}) - describe('useFileUpload', () => { const defaultOptions = { fileList: [] as FileItem[], @@ -164,6 +156,7 @@ describe('useFileUpload', () => { 'keeps the explicit $allowedExtensions override while subscribing to supported formats', async ({ allowedExtensions, expectedAccept, uploadCount }) => { const queryClient = createConsoleQueryClient() + queryClient.setQueryData(consoleQuery.files.upload.get.queryKey(), fileUploadConfig) request.mockImplementation(async () => Response.json(supportedFormats)) const Uploader = () => { const { fileUploaderRef, fileChangeHandle, acceptTypes } = useFileUpload({ @@ -387,7 +380,7 @@ describe('useFileUpload', () => { // Should be called with at most batch_count_limit files if (prepareFileList.mock.calls.length > 0) { const calledFiles = prepareFileList.mock.calls[0]![0] - expect(calledFiles.length).toBeLessThanOrEqual(mockFileUploadConfig.batch_count_limit) + expect(calledFiles.length).toBeLessThanOrEqual(fileUploadConfig.batch_count_limit) } }) diff --git a/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx index d9d78f68f0c206..05e4323ffb65af 100644 --- a/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx +++ b/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx @@ -9,17 +9,6 @@ vi.mock('@/config', () => ({ ENABLE_WEBSITE_WATERCRAWL: false, })) -vi.mock('../../../index.module.css', () => ({ - default: { - dataSourceItem: 'ds-item', - active: 'active', - disabled: 'disabled', - datasetIcon: 'icon', - notion: 'notion-icon', - web: 'web-icon', - }, -})) - const { default: DataSourceTypeSelector } = await import('../data-source-type-selector') const defaultProps = { diff --git a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index 2bef20dbfe4b68..424960d9c64455 100644 --- a/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -83,8 +83,8 @@ vi.mock('@/context/dataset-detail', () => ({ })) const mockEmbeddingModelList = [ - { provider: 'openai', model: 'text-embedding-ada-002' }, - { provider: 'cohere', model: 'embed-english-v3.0' }, + { provider: 'openai', models: [{ model: 'text-embedding-ada-002', features: [] }] }, + { provider: 'cohere', models: [{ model: 'embed-english-v3.0', features: [] }] }, ] const mockDefaultEmbeddingModel = { provider: { provider: 'openai' }, @@ -125,8 +125,21 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useDefaultModel: () => ({ data: mockDefaultEmbeddingModel }), })) -const mockFetchDefaultProcessRuleMutate = vi.fn() -const mockResetFileEstimate = vi.fn() +const mockFetchDefaultProcessRuleMutate = vi.fn(() => + Promise.resolve({ + mode: 'custom', + rules: { + segmentation: { separator: '\\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\\n', max_tokens: 256 }, + }, + limits: { indexing_max_segmentation_tokens_length: 4000 }, + }), +) // Mock amplitude tracking (external service) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), @@ -156,10 +169,6 @@ vi.mock('@/app/components/datasets/common/document-picker/preview-document-picke /* oxlint-enable typescript/no-explicit-any */ })) -vi.mock('@/app/components/datasets/settings/utils', () => ({ - checkShowMultiModalTip: () => false, -})) - // Mock complex child components to avoid deep dependency chains when rendering StepTwo vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ ModelSelector: ({ @@ -2289,10 +2298,13 @@ describe('StepTwo Component', () => { async (key) => { const user = userEvent.setup() render() + await user.click( + screen.getByRole('button', { name: 'datasetCreation.stepTwo.previewChunk' }), + ) + expect(await screen.findByText('Chunk 1 content')).toBeInTheDocument() const input = screen.getByRole('textbox', { name: 'datasetCreation.stepTwo.separator' }) await user.click(input) await user.keyboard(key === 'ArrowLeft' ? '{Home}' : '{End}') - mockResetFileEstimate.mockClear() await user.keyboard(`{${key}}`) expect(screen.getByRole('radio', { name: 'datasetCreation.stepTwo.general' })).toBeChecked() @@ -2301,7 +2313,7 @@ describe('StepTwo Component', () => { ).not.toBeChecked() expect(input).toBeInTheDocument() expect(input).toHaveFocus() - expect(mockResetFileEstimate).not.toHaveBeenCalled() + expect(screen.getByText('Chunk 1 content')).toBeInTheDocument() }, ) @@ -2320,9 +2332,9 @@ describe('StepTwo Component', () => { }) describe('Initialization', () => { - it('should fetch default process rule when not in setting mode', () => { + it('should fetch default process rule when not in setting mode', async () => { render() - expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalled() + await waitFor(() => expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalled()) }) it('should apply config from rules when in setting mode with document detail', () => { @@ -2629,50 +2641,26 @@ describe('StepTwo Component', () => { }) }) +// Embedding catalog is fixed form metadata; requests owned by step two use real mutations. vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - const createdDocuments = (data: unknown) => - vi.fn(async (_input: unknown, options?: { onSuccess?: (data: unknown) => void }) => { - options?.onSuccess?.(data) - return data - }) return mockConsoleOperationHooks(await importOriginal(), { 'workspaces.current.models.modelTypes.byModelType.get': () => ({ data: mockEmbeddingModelList, }), - 'datasets.processRule.get': (options: { onSuccess?: (data: unknown) => void }) => ({ - mutate: (input: unknown) => { - mockFetchDefaultProcessRuleMutate(input) - options.onSuccess?.({ - mode: 'custom', - rules: { - segmentation: { separator: '\\n', max_tokens: 500, chunk_overlap: 50 }, - pre_processing_rules: [ - { id: 'remove_extra_spaces', enabled: true }, - { id: 'remove_urls_emails', enabled: false }, - ], - parent_mode: 'paragraph', - subchunk_segmentation: { separator: '\\n', max_tokens: 256 }, - }, - limits: { indexing_max_segmentation_tokens_length: 4000 }, - }) - }, - isPending: false, - }), - 'datasets.indexingEstimate.post': () => ({ - mutate: vi.fn(), - data: undefined, - isIdle: true, - isPending: false, - reset: mockResetFileEstimate, - }), - 'datasets.init.post': () => ({ - mutateAsync: createdDocuments({ dataset: { id: 'new-dataset-id' } }), - isPending: false, - }), - 'datasets.byDatasetId.documents.post': () => ({ - mutateAsync: createdDocuments({ document: { id: 'new-doc-id' } }), - isPending: false, - }), }) }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.processRule.get': () => mockFetchDefaultProcessRuleMutate(), + 'datasets.indexingEstimate.post': () => Promise.resolve(createMockEstimate()), + 'datasets.init.post': () => Promise.resolve({ dataset: { id: 'new-dataset-id' } }), + 'datasets.byDatasetId.documents.post': () => + Promise.resolve({ document: { id: 'new-doc-id' } }), + }), + } +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts index 6a0492fac6195c..c164a21d3cb24a 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts @@ -15,7 +15,6 @@ const datasetListKey = consoleQuery.datasets.get.key() const mocks = vi.hoisted(() => ({ toastNotify: vi.fn(), mutateAsync: vi.fn(), - isReRankModelSelected: vi.fn(() => true), trackEvent: vi.fn(), invalidDatasetList: vi.fn(), })) @@ -34,19 +33,16 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: mocks.trackEvent, })) -vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ - isReRankModelSelected: mocks.isReRankModelSelected, -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.init.post': () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), - 'datasets.byDatasetId.documents.post': () => ({ - mutateAsync: mocks.mutateAsync, - isPending: false, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.init.post': (input) => mocks.mutateAsync(input), + 'datasets.byDatasetId.documents.post': (input) => mocks.mutateAsync(input), }), - }) + } }) const { useDocumentCreation } = await import('../use-document-creation') @@ -81,7 +77,6 @@ describe('useDocumentCreation', () => { beforeEach(() => { vi.clearAllMocks() - mocks.isReRankModelSelected.mockReturnValue(true) }) describe('validateParams', () => { @@ -118,13 +113,16 @@ describe('useDocumentCreation', () => { }) it('should return false when rerank model not selected', () => { - mocks.isReRankModelSelected.mockReturnValue(false) const { result } = renderHookWithConsoleQuery(() => useDocumentCreation(defaultOptions)) - expect(result.current.validateParams(defaultValidationParams)).toBe(false) + expect( + result.current.validateParams({ + ...defaultValidationParams, + retrievalConfig: { ...defaultValidationParams.retrievalConfig, reranking_enable: true }, + }), + ).toBe(false) }) it('should skip embedding/rerank checks when isSetting is true', () => { - mocks.isReRankModelSelected.mockReturnValue(false) const { result } = renderHookWithConsoleQuery(() => useDocumentCreation({ ...defaultOptions, isSetting: true }), ) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts index b299dcd457d4f0..775578eaecec85 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts @@ -23,10 +23,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useDefaultModel: () => ({ data: mocks.defaultEmbeddingModel }), })) -vi.mock('@/app/components/datasets/settings/utils', () => ({ - checkShowMultiModalTip: vi.fn(() => false), -})) - const { IndexingType, useIndexingConfig } = await import('../use-indexing-config') describe('useIndexingConfig', () => { diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts index b32f0ec4e891b5..4e7bc9d4157314 100644 --- a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts @@ -1,27 +1,23 @@ import type { IndexingType } from '../use-indexing-config' import type { NotionPage } from '@/models/common' import type { ChunkingMode, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets' -import { renderHook } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { DataSourceProvider } from '@/models/common' import { DataSourceType } from '@/models/datasets' - -const mocks = vi.hoisted(() => ({ - mutate: vi.fn(), - reset: vi.fn(), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.indexingEstimate.post': () => ({ - mutate: mocks.mutate, - reset: mocks.reset, - data: { tokens: 100, total_segments: 5 }, - isIdle: true, - isPending: false, +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' + +const estimate = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.indexingEstimate.post': estimate, }), - }) + } }) const { useIndexingEstimate } = await import('../use-indexing-estimate') @@ -48,21 +44,32 @@ describe('useIndexingEstimate', () => { processRule: { mode: 'custom', rules: {} } as unknown as ProcessRule, } - const sentBody = () => mocks.mutate.mock.calls.at(-1)?.[0].body + const sentBody = () => estimate.mock.calls.at(-1)?.[0].body beforeEach(() => { - vi.clearAllMocks() + estimate.mockReset().mockResolvedValue({ tokens: 100, total_segments: 5 }) }) - it('exposes the estimate', () => { + it('moves from idle through pending to the estimate and clears the result on reset', async () => { + const request = createDeferred<{ tokens: number; total_segments: number }>() + estimate.mockReturnValue(request.promise) const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) - expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 }) + expect(result.current.isIdle).toBe(true) + expect(result.current.estimate).toBeUndefined() + act(() => result.current.fetchEstimate()) + await waitFor(() => expect(result.current.isPending).toBe(true)) + await act(async () => request.resolve({ tokens: 100, total_segments: 5 })) + await waitFor(() => expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 })) + expect(result.current.isPending).toBe(false) + act(() => result.current.reset()) + await waitFor(() => expect(result.current.isIdle).toBe(true)) + expect(result.current.estimate).toBeUndefined() }) describe('fetchEstimate', () => { - it('estimates the uploaded files', () => { + it('estimates the uploaded files', async () => { const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) - result.current.fetchEstimate() + await act(async () => result.current.fetchEstimate()) expect(sentBody()).toMatchObject({ info_list: { data_source_type: 'upload_file', file_info_list: { file_ids: ['f-1'] } }, @@ -71,11 +78,11 @@ describe('useIndexingEstimate', () => { }) }) - it('estimates the previewed Notion page', () => { + it('estimates the previewed Notion page', async () => { const { result } = renderHook(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), ) - result.current.fetchEstimate() + await act(async () => result.current.fetchEstimate()) expect(sentBody()).toMatchObject({ info_list: { @@ -91,11 +98,11 @@ describe('useIndexingEstimate', () => { }) }) - it('estimates the previewed website page', () => { + it('estimates the previewed website page', async () => { const { result } = renderHook(() => useIndexingEstimate({ ...defaultOptions, dataSourceType: DataSourceType.WEB }), ) - result.current.fetchEstimate() + await act(async () => result.current.fetchEstimate()) expect(sentBody()).toMatchObject({ info_list: { @@ -109,22 +116,4 @@ describe('useIndexingEstimate', () => { }) }) }) - - describe('state properties', () => { - it('should expose isIdle', () => { - const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) - expect(result.current.isIdle).toBe(true) - }) - - it('should expose isPending', () => { - const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) - expect(result.current.isPending).toBe(false) - }) - - it('should expose reset function', () => { - const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) - result.current.reset() - expect(mocks.reset).toHaveBeenCalledOnce() - }) - }) }) diff --git a/web/app/components/datasets/create/website/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/__tests__/index.spec.tsx index fd19ae6061acad..10a4fa70081c8f 100644 --- a/web/app/components/datasets/create/website/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/index.spec.tsx @@ -1,13 +1,13 @@ import type { DatasourceProviderAuthResponse } from '@dify/contracts/api/console/auth/types.gen' import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import Website from '../index' -const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({ +const { mockRouterPush } = vi.hoisted(() => ({ mockRouterPush: vi.fn(), - mockSetSettingsDestination: vi.fn(), })) vi.mock('@/next/navigation', () => ({ @@ -16,11 +16,6 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - vi.mock('../index.module.css', () => ({ default: { jinaLogo: 'jina-logo', @@ -114,6 +109,9 @@ type RenderProps = { enableWatercrawl?: boolean } +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + const renderWebsite = ({ authedDataSourceList = [], enableJina = true, @@ -270,13 +268,15 @@ describe('Website', () => { }) describe('NoData Config', () => { - it('should navigate to data source settings when NoData onConfig is triggered', () => { + it('should navigate to data source settings when NoData onConfig is triggered', async () => { renderWebsite({ authedDataSourceList: [] }) const configButton = screen.getByTestId('no-data-config-button') fireEvent.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) expect(mockRouterPush).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx index c7add52e3d6995..5ce6e7c6169b53 100644 --- a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx @@ -1,8 +1,15 @@ import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + act, + fireEvent, + render as renderWithoutUrlState, + screen, + waitFor, +} from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { createHttpError } from '@/service/http-error' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' // Component Import (after mocks) import FireCrawl from '../index' @@ -11,9 +18,8 @@ import FireCrawl from '../index' // Mock API service const mockCreateFirecrawlTask = vi.fn() const mockCheckFirecrawlTaskStatus = vi.fn() -const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({ +const { mockRouterPush } = vi.hoisted(() => ({ mockRouterPush: vi.fn(), - mockSetSettingsDestination: vi.fn(), })) vi.mock('@/next/navigation', () => ({ @@ -27,11 +33,6 @@ vi.mock('@/service/datasets', () => ({ checkFirecrawlTaskStatus: (...args: unknown[]) => mockCheckFirecrawlTaskStatus(...args), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - // Mock sleep utility to speed up tests vi.mock('@/utils', () => ({ sleep: vi.fn(() => Promise.resolve()), @@ -80,6 +81,9 @@ const createDeferred = () => { // FireCrawl Component Tests +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('FireCrawl', () => { const mockOnPreview = vi.fn() const mockOnCheckedCrawlResultChange = vi.fn() @@ -177,7 +181,9 @@ describe('FireCrawl', () => { const configButton = screen.getByText(/configureFirecrawl/i) await user.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) expect(mockRouterPush).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx index 1c2ca7cd76e66d..817465d2058a61 100644 --- a/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx @@ -1,15 +1,15 @@ import type { Mock } from 'vite-plus/test' import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets' import { createHttpError } from '@/service/http-error' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { sleep } from '@/utils' import JinaReader from '../index' -const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({ +const { mockRouterPush } = vi.hoisted(() => ({ mockRouterPush: vi.fn(), - mockSetSettingsDestination: vi.fn(), })) vi.mock('@/next/navigation', () => ({ @@ -27,11 +27,6 @@ vi.mock('@/utils', () => ({ sleep: vi.fn(() => Promise.resolve()), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - // Mock doc link context vi.mock('@/context/i18n', () => ({ useDocLink: () => () => 'https://docs.example.com', @@ -68,6 +63,9 @@ const createDefaultProps = (overrides: Partial[0]> ...overrides, }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('JinaReader', () => { beforeEach(() => { vi.clearAllMocks() @@ -399,14 +397,12 @@ describe('JinaReader', () => { const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader') fireEvent.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledTimes(1) expect(mockRouterPush).not.toHaveBeenCalled() // Rerender and click again rerender() fireEvent.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledTimes(2) expect(mockRouterPush).not.toHaveBeenCalled() }) @@ -445,7 +441,9 @@ describe('JinaReader', () => { const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader') await userEvent.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) expect(mockRouterPush).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx index ebf2613dea2456..0b0e5035b854a4 100644 --- a/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx @@ -1,15 +1,15 @@ import type { Mock } from 'vite-plus/test' import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets' import { createHttpError } from '@/service/http-error' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { sleep } from '@/utils' import WaterCrawl from '../index' -const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({ +const { mockRouterPush } = vi.hoisted(() => ({ mockRouterPush: vi.fn(), - mockSetSettingsDestination: vi.fn(), })) vi.mock('@/next/navigation', () => ({ @@ -27,11 +27,6 @@ vi.mock('@/utils', () => ({ sleep: vi.fn(() => Promise.resolve()), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - // Mock i18n context vi.mock('@/context/i18n', () => ({ useDocLink: () => (path?: string) => @@ -69,6 +64,9 @@ const createDefaultProps = (overrides: Partial[0]> ...overrides, }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('WaterCrawl', () => { beforeEach(() => { vi.clearAllMocks() @@ -395,14 +393,12 @@ describe('WaterCrawl', () => { const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl') fireEvent.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledTimes(1) expect(mockRouterPush).not.toHaveBeenCalled() // Rerender and click again rerender() fireEvent.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledTimes(2) expect(mockRouterPush).not.toHaveBeenCalled() }) @@ -447,7 +443,9 @@ describe('WaterCrawl', () => { const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl') await userEvent.click(configButton) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) expect(mockRouterPush).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx index 7fac64be484642..2880c03dd41d80 100644 --- a/web/app/components/datasets/documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { DocumentWithSegmentsListResponse as DocumentListResponse } from '@dify/contracts/api/console/datasets/types.gen' import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { DataSourceType } from '@/models/datasets' import { consoleQuery } from '@/service/console' @@ -10,13 +10,6 @@ import { render as renderWithConsoleState } from '@/test/console/render' import { useDocumentsPageState } from '../hooks/use-documents-page-state' import Documents from '../index' -type DocumentListQueryOptions = { - input: { query: { status?: string } } - refetchInterval?: (query: { - state: { data?: { data: Array<{ indexing_status?: string | null }> } } - }) => number | false -} - const expectDocumentsInvalidated = () => { expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ queryKey: consoleQuery.datasets.byDatasetId.documents.get.key({ @@ -28,13 +21,19 @@ const expectDocumentsInvalidated = () => { }) } +let documentData: DocumentListResponse | undefined + const render = (ui: Parameters[0]) => { const queryClient = createAccountProfileQueryClient({ id: 'test-user' }) - return renderWithConsoleState(ui, { + queryClient.setQueryDefaults(consoleQuery.datasets.byDatasetId.documents.get.key(), { + initialData: documentData, + }) + const rendered = renderWithConsoleState(ui, { wrapper: ({ children }: { children: ReactNode }) => ( {children} ), }) + return { ...rendered, queryClient } } // Type for mock selector function - use `as MockState` to bypass strict type checking in tests @@ -96,51 +95,42 @@ vi.mock('@/features/system-features/state', async () => { })) }) -// Mock the generated document list query -const { mockDocumentListQuery } = vi.hoisted(() => ({ - mockDocumentListQuery: vi.fn( - ( - _options?: unknown, - ): { data?: DocumentListResponse; isLoading: boolean; refetch: () => void } => ({ - data: { - data: [ - { - id: 'doc-1', - name: 'Document 1', - indexing_status: 'completed', - data_source_type: 'upload_file', - position: 1, - enabled: true, - }, - { - id: 'doc-2', - name: 'Document 2', - indexing_status: 'indexing', - data_source_type: 'upload_file', - position: 2, - enabled: true, - }, - ], - total: 2, - page: 1, - limit: 10, - has_more: false, - } as DocumentListResponse, - isLoading: false, - refetch: vi.fn(), +const getDocuments = vi.hoisted(() => vi.fn()) +const defaultDocumentData = { + data: [ + { + id: 'doc-1', + name: 'Document 1', + indexing_status: 'completed', + data_source_type: 'upload_file', + position: 1, + enabled: true, + }, + { + id: 'doc-2', + name: 'Document 2', + indexing_status: 'indexing', + data_source_type: 'upload_file', + position: 2, + enabled: true, + }, + ], + total: 2, + page: 1, + limit: 10, + has_more: false, +} as DocumentListResponse +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.get': getDocuments, }), - ), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.get': (options: unknown) => mockDocumentListQuery(options), - }) + } }) -// Mock segment service hooks -// Mock metadata hook vi.mock('../../metadata/hooks/use-edit-dataset-metadata', () => ({ default: vi.fn(() => ({ isShowEditModal: false, @@ -319,6 +309,8 @@ describe('Documents', () => { beforeEach(() => { vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.clearAllMocks() + documentData = defaultDocumentData + getDocuments.mockReset().mockImplementation(() => new Promise(() => {})) mockPush.mockClear() // Reset context mocks to default vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { @@ -350,50 +342,46 @@ describe('Documents', () => { expect(screen.getByTestId('list-documents-count')).toHaveTextContent('2') }) - it('should render loading state when isLoading is true', () => { - mockDocumentListQuery.mockReturnValueOnce({ - data: undefined, - isLoading: true, - refetch: vi.fn(), - }) + it('shows loading until the initial document request resolves', async () => { + documentData = undefined + getDocuments.mockResolvedValue(defaultDocumentData) render() expect(screen.queryByTestId('documents-list')).not.toBeInTheDocument() + expect(await screen.findByTestId('documents-list')).toBeInTheDocument() }) - it('should keep rendering list when loading with existing data', () => { - mockDocumentListQuery.mockReturnValueOnce({ - data: { - data: [ - { - id: 'doc-1', - name: 'Document 1', - indexing_status: 'completed', - data_source_type: 'upload_file', - position: 1, - enabled: true, - }, - ], - total: 1, - page: 1, - limit: 10, - has_more: false, - } as DocumentListResponse, - isLoading: true, - refetch: vi.fn(), - }) + it('keeps cached documents visible during a background refresh', async () => { + documentData = { + data: [ + { + id: 'doc-1', + name: 'Document 1', + indexing_status: 'completed', + data_source_type: 'upload_file', + position: 1, + enabled: true, + }, + ], + total: 1, + page: 1, + limit: 10, + has_more: false, + } as DocumentListResponse - render() + const { queryClient } = render() + act(() => { + void queryClient.invalidateQueries({ + queryKey: consoleQuery.datasets.byDatasetId.documents.get.key(), + }) + }) + await waitFor(() => expect(getDocuments).toHaveBeenCalledOnce()) expect(screen.getByTestId('documents-list')).toBeInTheDocument() expect(screen.getByTestId('list-documents-count')).toHaveTextContent('1') }) it('should render empty element when no documents exist', () => { - mockDocumentListQuery.mockReturnValueOnce({ - data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, - isLoading: false, - refetch: vi.fn(), - }) + documentData = { data: [], total: 0, page: 1, limit: 10, has_more: false } render() expect(screen.getByTestId('empty-element')).toBeInTheDocument() @@ -418,11 +406,7 @@ describe('Documents', () => { return selector(mockState as MockState) }, ) - mockDocumentListQuery.mockReturnValueOnce({ - data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, - isLoading: false, - refetch: vi.fn(), - }) + documentData = { data: [], total: 0, page: 1, limit: 10, has_more: false } render() expect(screen.getByTestId('empty-type')).toHaveTextContent('sync') @@ -553,11 +537,7 @@ describe('Documents', () => { return selector(mockState as MockState) }, ) - mockDocumentListQuery.mockReturnValueOnce({ - data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, - isLoading: false, - refetch: vi.fn(), - }) + documentData = { data: [], total: 0, page: 1, limit: 10, has_more: false } render() @@ -567,81 +547,56 @@ describe('Documents', () => { }) }) - describe('Query Options', () => { - it('should pass function refetchInterval to the document list query', () => { - render() - - const payload = mockDocumentListQuery.mock.calls.at(-1)?.[0] as - | DocumentListQueryOptions - | undefined - expect(payload).toBeDefined() - expect(typeof payload?.refetchInterval).toBe('function') + describe('polling', () => { + afterEach(() => { + vi.useRealTimers() }) - it('should stop polling when all documents are in terminal statuses', () => { + it('polls processing documents and stops after every document reaches a terminal state', async () => { + vi.useFakeTimers() + const completed = { + ...defaultDocumentData, + data: defaultDocumentData.data.map((document) => ({ + ...document, + indexing_status: 'completed', + })), + } + getDocuments.mockResolvedValue(completed) render() - - const payload = mockDocumentListQuery.mock.calls.at(-1)?.[0] as - | DocumentListQueryOptions - | undefined - const refetchInterval = payload?.refetchInterval - expect(typeof refetchInterval).toBe('function') - if (typeof refetchInterval !== 'function') - throw new Error('Expected function refetchInterval') - - const interval = refetchInterval({ - state: { - data: { - data: [ - { indexing_status: 'completed' }, - { indexing_status: 'paused' }, - { indexing_status: 'error' }, - ], - }, - }, + expect(getDocuments).not.toHaveBeenCalled() + await act(async () => { + await vi.advanceTimersByTimeAsync(2500) }) - - expect(interval).toBe(false) + expect(getDocuments).toHaveBeenCalledOnce() + await act(async () => { + await vi.advanceTimersByTimeAsync(10000) + }) + expect(getDocuments).toHaveBeenCalledOnce() }) - it('should keep polling for transient status filters', () => { - vi.mocked(useDocumentsPageState).mockReturnValueOnce({ - inputValue: '', - debouncedSearchValue: '', - handleInputChange: mockHandleInputChange, - statusFilterValue: 'indexing', - sortValue: '-created_at' as const, + it('keeps polling a transient status filter even when its current result is empty', async () => { + vi.useFakeTimers() + const original = vi.mocked(useDocumentsPageState).getMockImplementation()! + vi.mocked(useDocumentsPageState).mockImplementation(() => ({ + ...original(), normalizedStatusFilterValue: 'indexing', - handleStatusFilterChange: mockHandleStatusFilterChange, - handleStatusFilterClear: mockHandleStatusFilterClear, - handleSortChange: mockHandleSortChange, - currPage: 0, - limit: 10, - handlePageChange: mockHandlePageChange, - handleLimitChange: mockHandleLimitChange, - selectedIds: [] as string[], - setSelectedIds: mockSetSelectedIds, - }) - - render() - - const payload = mockDocumentListQuery.mock.calls.find( - ([options]) => (options as DocumentListQueryOptions).input.query.status === 'indexing', - )?.[0] as DocumentListQueryOptions | undefined - const refetchInterval = payload?.refetchInterval - expect(typeof refetchInterval).toBe('function') - if (typeof refetchInterval !== 'function') - throw new Error('Expected function refetchInterval') - - const interval = refetchInterval({ - state: { - data: { - data: [{ indexing_status: 'completed' }], - }, - }, - }) - - expect(interval).toBe(2500) + statusFilterValue: 'indexing', + })) + documentData = { data: [], total: 0, page: 1, limit: 10, has_more: false } + getDocuments.mockResolvedValue(documentData) + try { + render() + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(getDocuments).toHaveBeenCalledTimes(2) + expect(getDocuments).toHaveBeenLastCalledWith({ + params: { dataset_id: 'test-dataset-id' }, + query: expect.objectContaining({ status: 'indexing' }), + }) + } finally { + vi.mocked(useDocumentsPageState).mockImplementation(original) + } }) }) @@ -685,23 +640,7 @@ describe('Documents', () => { }) it('should handle empty documents array', () => { - mockDocumentListQuery.mockReturnValueOnce({ - data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, - isLoading: false, - refetch: vi.fn(), - }) - - render() - - expect(screen.getByTestId('empty-element')).toBeInTheDocument() - }) - - it('should handle undefined documentsRes', () => { - mockDocumentListQuery.mockReturnValueOnce({ - data: undefined, - isLoading: false, - refetch: vi.fn(), - }) + documentData = { data: [], total: 0, page: 1, limit: 10, has_more: false } render() diff --git a/web/app/components/datasets/documents/__tests__/status-filter.spec.ts b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts index 490dd8984f91c5..a7717fe8b3006e 100644 --- a/web/app/components/datasets/documents/__tests__/status-filter.spec.ts +++ b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts @@ -1,19 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter' -vi.mock('@/models/datasets', () => ({ - DisplayStatusList: [ - 'queuing', - 'indexing', - 'paused', - 'error', - 'available', - 'enabled', - 'disabled', - 'archived', - ], -})) - describe('status-filter', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/datasets/documents/components/__tests__/list.spec.tsx b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx index 9da3d5a4a0cad4..c1ff9cf81f8348 100644 --- a/web/app/components/datasets/documents/components/__tests__/list.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx @@ -80,15 +80,6 @@ vi.mock('../../detail/completed/common/batch-action', () => ({ ), })) -vi.mock('../../rename-modal', () => ({ - default: ({ name, onClose }: { name: string; onClose: () => void }) => ( -
- {name} - -
- ), -})) - vi.mock('@/app/components/datasets/metadata/edit-metadata-batch/modal', () => ({ default: ({ onHide }: { onHide: () => void }) => (
diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions-with-query-client.spec.tsx b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions-with-query-client.spec.tsx index 095af89b2dcfcd..40db9ebaff6849 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions-with-query-client.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions-with-query-client.spec.tsx @@ -3,24 +3,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { DocumentActionType } from '@/models/datasets' -import * as useDocument from '@/service/knowledge/use-document' +import { createDeferred } from '@/test/deferred' import { useDocumentActions } from '../use-document-actions' -vi.mock('@/service/knowledge/use-document') - const { mockMutateAsync } = vi.hoisted(() => ({ mockMutateAsync: vi.fn() })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - const mutation = () => ({ mutateAsync: mockMutateAsync, isPending: false }) - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.status.byAction.batch.patch': mutation, - 'datasets.byDatasetId.documents.generateSummary.post': mutation, - 'datasets.byDatasetId.documents.delete': mutation, - 'datasets.byDatasetId.retry.post': mutation, - }) -}) - -const mockUseDocumentDownloadZip = vi.mocked(useDocument.useDocumentDownloadZip) const createConsoleQueryClient = () => new QueryClient({ @@ -37,32 +23,23 @@ const createWrapper = () => { ) } +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': mockMutateAsync, + 'datasets.byDatasetId.documents.generateSummary.post': mockMutateAsync, + 'datasets.byDatasetId.documents.delete': mockMutateAsync, + 'datasets.byDatasetId.retry.post': mockMutateAsync, + }), + } +}) + describe('useDocumentActions', () => { beforeEach(() => { vi.clearAllMocks() - - const createMockMutation = () => ({ - mutateAsync: mockMutateAsync, - isPending: false, - isError: false, - isSuccess: false, - isIdle: true, - data: undefined, - error: null, - mutate: vi.fn(), - reset: vi.fn(), - status: 'idle' as const, - variables: undefined, - context: undefined, - failureCount: 0, - failureReason: null, - submittedAt: 0, - }) - - mockUseDocumentDownloadZip.mockReturnValue({ - ...createMockMutation(), - isPending: false, - } as unknown as ReturnType) }) describe('handleAction', () => { @@ -203,40 +180,10 @@ describe('useDocumentActions', () => { }) describe('handleBatchDownload', () => { - it('should not proceed when already downloading', async () => { - mockUseDocumentDownloadZip.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: true, - } as unknown as ReturnType) - - const { result } = renderHook( - () => - useDocumentActions({ - datasetId: 'ds1', - selectedIds: ['doc1'], - downloadableSelectedIds: ['doc1'], - onUpdate: vi.fn(), - onClearSelection: vi.fn(), - }), - { wrapper: createWrapper() }, - ) - - await act(async () => { - await result.current.handleBatchDownload() - }) - - expect(mockMutateAsync).not.toHaveBeenCalled() - }) - it('should call download mutation with downloadable ids', async () => { const mockBlob = new Blob(['test']) mockMutateAsync.mockResolvedValue(mockBlob) - mockUseDocumentDownloadZip.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: false, - } as unknown as ReturnType) - const { result } = renderHook( () => useDocumentActions({ @@ -260,27 +207,34 @@ describe('useDocumentActions', () => { }) }) - describe('isDownloadingZip', () => { - it('should reflect isPending state from mutation', () => { - mockUseDocumentDownloadZip.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: true, - } as unknown as ReturnType) - - const { result } = renderHook( - () => - useDocumentActions({ - datasetId: 'ds1', - selectedIds: [], - downloadableSelectedIds: [], - onUpdate: vi.fn(), - onClearSelection: vi.fn(), - }), - { wrapper: createWrapper() }, - ) - - expect(result.current.isDownloadingZip).toBe(true) + it('tracks the download request while the archive is being prepared', async () => { + const pending = createDeferred() + mockMutateAsync.mockReturnValue(pending.promise) + const { result } = renderHook( + () => + useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + let download: Promise + act(() => { + download = result.current.handleBatchDownload() }) + await waitFor(() => expect(result.current.isDownloadingZip).toBe(true)) + await act(async () => { + await result.current.handleBatchDownload() + }) + expect(mockMutateAsync).toHaveBeenCalledOnce() + await act(async () => { + pending.resolve(new Blob(['archive'])) + await download + }) + await waitFor(() => expect(result.current.isDownloadingZip).toBe(false)) }) describe('error handling', () => { @@ -338,11 +292,6 @@ describe('useDocumentActions', () => { it('should show error toast when handleBatchDownload fails', async () => { mockMutateAsync.mockRejectedValue(new Error('Download failed')) - mockUseDocumentDownloadZip.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: false, - } as unknown as ReturnType) - const { result } = renderHook( () => useDocumentActions({ @@ -366,11 +315,6 @@ describe('useDocumentActions', () => { it('should show error toast when handleBatchDownload returns null blob', async () => { mockMutateAsync.mockResolvedValue(null) - mockUseDocumentDownloadZip.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: false, - } as unknown as ReturnType) - const { result } = renderHook( () => useDocumentActions({ @@ -446,3 +390,8 @@ describe('useDocumentActions', () => { }) }) }) + +vi.mock('@/service/datasets', async (importOriginal) => ({ + ...(await importOriginal()), + downloadDocumentsZip: mockMutateAsync, +})) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts index 72d0f330740276..d504f6263780dc 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts @@ -1,6 +1,7 @@ -import { act, renderHook } from '@testing-library/react' +import { act } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { DocumentActionType } from '@/models/datasets' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { useDocumentActions } from '../use-document-actions' const { mockToastSuccess, mockToastError } = vi.hoisted(() => ({ @@ -15,28 +16,12 @@ const mockDisable = vi.fn() const mockDelete = vi.fn() const mockRetryIndex = vi.fn() const mockDownloadZip = vi.fn() -let mockIsDownloadingZip = false const statusMocks: Record unknown> = { archive: (input) => mockArchive(input), enable: (input) => mockEnable(input), disable: (input) => mockDisable(input), } -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.status.byAction.batch.patch': () => ({ - mutateAsync: (input: { params: { action: string } }) => - statusMocks[input.params.action]!(input), - }), - 'datasets.byDatasetId.documents.generateSummary.post': () => ({ mutateAsync: mockSummary }), - 'datasets.byDatasetId.documents.delete': () => ({ mutateAsync: mockDelete }), - 'datasets.byDatasetId.retry.post': () => ({ mutateAsync: mockRetryIndex }), - }) -}) -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentDownloadZip: () => ({ mutateAsync: mockDownloadZip, isPending: mockIsDownloadingZip }), -})) vi.mock('@/app/notifications', () => ({ toast: { @@ -50,6 +35,22 @@ vi.mock('@/utils/download', () => ({ downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': (input: { + params: { action: string } + }) => statusMocks[input.params.action]!(input), + 'datasets.byDatasetId.documents.generateSummary.post': (input) => mockSummary(input), + 'datasets.byDatasetId.documents.delete': (input) => mockDelete(input), + 'datasets.byDatasetId.retry.post': (input) => mockRetryIndex(input), + }), + } +}) + describe('useDocumentActions', () => { const defaultOptions = { datasetId: 'ds-1', @@ -61,7 +62,6 @@ describe('useDocumentActions', () => { beforeEach(() => { vi.clearAllMocks() - mockIsDownloadingZip = false }) it('should return expected functions and state', () => { @@ -237,3 +237,8 @@ describe('useDocumentActions', () => { }) }) }) + +vi.mock('@/service/datasets', async (importOriginal) => ({ + ...(await importOriginal()), + downloadDocumentsZip: (...args: unknown[]) => mockDownloadZip(...args), +})) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx index 25780702cbcb6a..b8163edde2cb9e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx @@ -12,9 +12,6 @@ vi.mock('@/hooks/use-theme', () => ({ })) // Mock theme types -vi.mock('@/types/app', () => ({ - Theme: { dark: 'dark', light: 'light' }, -})) // Mock SimplePieChart with dynamic import handling vi.mock('next/dynamic', () => ({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx index 4aef4ff991dbfb..488e15e5823282 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx @@ -42,17 +42,8 @@ vi.mock('@/app/notifications', () => ({ })) // Mock file uploader utils -vi.mock('@/app/components/base/file-uploader/utils', () => ({ - getFileUploadErrorMessage: (e: Error, defaultMsg: string) => e.message || defaultMsg, -})) // Mock format utils used by the shared hook -vi.mock('@/utils/format', () => ({ - getFileExtension: (filename: string) => { - const parts = filename.split('.') - return parts[parts.length - 1] || '' - }, -})) // Mock i18n config vi.mock('@/i18n/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx index cb2b11fe5c62c4..6e532a54f41ee6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { VarKindType } from '@/app/components/workflow/nodes/_base/types' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import OnlineDocuments from '../index' // Mock useDocLink - context hook requires mocking @@ -18,12 +19,6 @@ vi.mock('@/context/dataset-detail', () => ({ selector({ dataset: { pipeline_id: mockPipelineId } }), })) -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - // Mock ssePost - API service requires mocking const { mockSsePost } = vi.hoisted(() => ({ mockSsePost: vi.fn(), @@ -227,6 +222,9 @@ const createDefaultProps = (overrides?: Partial): OnlineDo ...overrides, }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('OnlineDocuments', () => { beforeEach(() => { vi.clearAllMocks() @@ -245,7 +243,7 @@ describe('OnlineDocuments', () => { // Reset context values mockPipelineId = 'pipeline-123' - mockSetSettingsDestination.mockClear() + onUrlUpdate.mockClear() // Default mock return values mockUseGetDataSourceAuth.mockReturnValue({ @@ -614,13 +612,15 @@ describe('OnlineDocuments', () => { expect(mockStoreState.setCurrentDocument).toHaveBeenCalled() }) - it('should have stable handleSetting callback', () => { + it('should have stable handleSetting callback', async () => { const props = createDefaultProps() render() fireEvent.click(screen.getByTestId('header-config-btn')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) }) @@ -709,13 +709,15 @@ describe('OnlineDocuments', () => { expect(mockStoreState.setCurrentDocument).toHaveBeenCalled() }) - it('should handle configuration button click', () => { + it('should handle configuration button click', async () => { const props = createDefaultProps() render() fireEvent.click(screen.getByTestId('header-config-btn')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) it('should handle credential change', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx index 7d7aeac3602658..5fcdf8bb4b1463 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx @@ -1,14 +1,14 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { OnlineDriveFile } from '@/models/pipeline' import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import OnlineDrive from '../index' const mocks = vi.hoisted(() => ({ pipelineId: 'pipeline-123' as string | undefined, docLink: vi.fn((path?: string) => `https://docs.example.com${path || ''}`), - setSettingsDestination: vi.fn(), ssePost: vi.fn(), toastError: vi.fn(), useGetDataSourceAuth: vi.fn(), @@ -48,11 +48,6 @@ vi.mock('@/context/dataset-detail', () => ({ ) => selector({ dataset: { pipeline_id: mocks.pipelineId } }), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mocks.setSettingsDestination] } -}) - vi.mock('@/service/base', () => ({ ssePost: (...args: unknown[]) => mocks.ssePost(...args), })) @@ -240,6 +235,9 @@ const createOnlineDriveFile = (overrides: Partial = {}): Online ...overrides, }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + const renderOnlineDrive = (props: Partial> = {}) => { return render( { expect(mockStoreState.setBucket).toHaveBeenCalledTimes(1) }) - it('should open integrations settings and forward credential changes', () => { + it('should open integrations settings and forward credential changes', async () => { const onCredentialChange = vi.fn() renderOnlineDrive({ onCredentialChange, @@ -444,7 +442,9 @@ describe('OnlineDrive', () => { fireEvent.click(screen.getByRole('button', { name: 'Configure' })) fireEvent.click(screen.getByRole('button', { name: 'Change Credential' })) - expect(mocks.setSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) expect(onCredentialChange).toHaveBeenCalledWith('credential-2') }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx index 872380eb70a2dc..c984d06d83a188 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx @@ -1,21 +1,10 @@ import type { OnlineDriveFile } from '@/models/pipeline' -import { fireEvent, render, screen, within } from '@testing-library/react' +import { act, fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { OnlineDriveFileType } from '@/models/pipeline' import FileList from '../index' -// Mock ahooks useDebounceFn: required because tests verify the debounced -// callback is invoked with specific arguments (mockDebounceFnRun assertions). -const mockDebounceFnRun = vi.fn() -const mockDebounceFnCancel = vi.fn() -vi.mock('ahooks', () => ({ - useDebounceFn: (fn: (...args: unknown[]) => void) => { - mockDebounceFnRun.mockImplementation(fn) - return { run: mockDebounceFnRun, cancel: mockDebounceFnCancel } - }, -})) - // Mock store - context provider requires mocking const mockStoreState = { setNextPageParameters: vi.fn(), @@ -81,8 +70,9 @@ describe('FileList', () => { beforeEach(() => { vi.clearAllMocks() resetMockStoreState() - mockDebounceFnRun.mockClear() - mockDebounceFnCancel.mockClear() + }) + afterEach(() => { + vi.useRealTimers() }) describe('Rendering', () => { @@ -270,7 +260,8 @@ describe('FileList', () => { }) describe('debounced keywords update', () => { - it('should call updateKeywords with debounce when input changes', () => { + it('should delay the search until typing has stopped', () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render() @@ -280,7 +271,11 @@ describe('FileList', () => { fireEvent.change(input, { target: { value: 'debounced-value' } }) - expect(mockDebounceFnRun).toHaveBeenCalledWith('debounced-value') + expect(mockUpdateKeywords).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(499)) + expect(mockUpdateKeywords).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(mockUpdateKeywords).toHaveBeenCalledExactlyOnceWith('debounced-value') }) }) }) @@ -300,20 +295,8 @@ describe('FileList', () => { expect(input).toHaveValue('typed-text') }) - it('should trigger debounced updateKeywords on input change', () => { - const mockUpdateKeywords = vi.fn() - const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) - render() - const input = screen.getByPlaceholderText( - 'datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder', - ) - - fireEvent.change(input, { target: { value: 'search-term' } }) - - expect(mockDebounceFnRun).toHaveBeenCalledWith('search-term') - }) - - it('should handle multiple sequential input changes', () => { + it('should search only for the latest input after rapid changes', () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render() @@ -325,15 +308,16 @@ describe('FileList', () => { fireEvent.change(input, { target: { value: 'ab' } }) fireEvent.change(input, { target: { value: 'abc' } }) - expect(mockDebounceFnRun).toHaveBeenCalledTimes(3) - expect(mockDebounceFnRun).toHaveBeenLastCalledWith('abc') + act(() => vi.advanceTimersByTime(500)) + expect(mockUpdateKeywords).toHaveBeenCalledExactlyOnceWith('abc') expect(input).toHaveValue('abc') }) }) describe('handleResetKeywords', () => { it('should cancel the pending search before clearing the query', async () => { - const user = userEvent.setup() + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const mockResetKeywords = vi.fn() const props = createDefaultProps({ resetKeywords: mockResetKeywords }) render() @@ -344,8 +328,8 @@ describe('FileList', () => { await user.type(input, 'report') await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) - expect(mockDebounceFnRun).toHaveBeenLastCalledWith('report') - expect(mockDebounceFnCancel).toHaveBeenCalledOnce() + act(() => vi.advanceTimersByTime(500)) + expect(props.updateKeywords).not.toHaveBeenCalled() expect(mockResetKeywords).toHaveBeenCalledOnce() expect(input).toHaveValue('') }) @@ -421,7 +405,8 @@ describe('FileList', () => { fireEvent.change(input, { target: { value: ' ' } }) expect(input).toHaveValue(' ') - expect(mockDebounceFnRun).toHaveBeenCalledWith(' ') + // The input remains editable while the search is debounced. + expect(props.updateKeywords).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx index 845ddeb54bedb5..f0a01d8746ddb8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { CrawlResultItem } from '@/models/datasets' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { CrawlStep } from '@/models/datasets' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import WebsiteCrawl from '../index' // Mock useDocLink - context hook requires mocking @@ -19,12 +20,6 @@ vi.mock('@/context/dataset-detail', () => ({ ) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - // Mock ssePost - API service requires mocking const { mockSsePost } = vi.hoisted(() => ({ mockSsePost: vi.fn(), @@ -268,6 +263,9 @@ const createDefaultProps = (overrides?: Partial): WebsiteCraw ...overrides, }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('WebsiteCrawl', () => { beforeEach(() => { vi.clearAllMocks() @@ -286,7 +284,7 @@ describe('WebsiteCrawl', () => { // Reset context values mockPipelineId = 'pipeline-123' - mockSetSettingsDestination.mockClear() + onUrlUpdate.mockClear() // Default mock return values mockUseGetDataSourceAuth.mockReturnValue({ @@ -639,13 +637,15 @@ describe('WebsiteCrawl', () => { expect(mockStoreState.setPreviewIndex).toHaveBeenCalledWith(0) }) - it('should have stable handleSetting callback', () => { + it('should have stable handleSetting callback', async () => { const props = createDefaultProps() render() fireEvent.click(screen.getByTestId('header-config-btn')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) it('should have stable handleCredentialChange that resets state', () => { @@ -674,13 +674,15 @@ describe('WebsiteCrawl', () => { }) }) - it('should handle configuration button click', () => { + it('should handle configuration button click', async () => { const props = createDefaultProps() render() fireEvent.click(screen.getByTestId('header-config-btn')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('data-source'), + ) }) it('should handle credential change', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx index 4671d25c33b983..4d09e414aadf67 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx @@ -1,907 +1,54 @@ -import type { RAGPipelineVariables } from '@/models/pipeline' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { toast } from '@/app/notifications' import { CrawlStep } from '@/models/datasets' -import { PipelineInputVarType } from '@/models/pipeline' -import { expectLoadingButton } from '@/test/button' +import { createPipelineVariable } from '@/test/pipeline-variables' import Options from '../index' -const { mockToastError } = vi.hoisted(() => ({ - mockToastError: vi.fn(), -})) - -vi.mock('@/app/notifications', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - toast: { - ...actual.toast, - error: mockToastError, - }, - } -}) - -// Mock useInitialData and useConfigurations hooks -const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({ - mockUseInitialData: vi.fn(), - mockUseConfigurations: vi.fn(), -})) - -vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ - useInitialData: mockUseInitialData, - useConfigurations: mockUseConfigurations, -})) - -// Mock BaseField -const mockBaseField = vi.fn() -vi.mock('@/app/components/base/form/form-scenarios/base/field', () => { - const MockBaseFieldFactory = (props: Record) => { - mockBaseField(props) - const config = props.config as { variable?: string; label?: string } | undefined - const MockField = ({ - form, - }: { - form: { - getFieldValue?: (field: string) => string - setFieldValue?: (field: string, value: string) => void - } - }) => ( -
- {config?.label} - form.setFieldValue?.(config?.variable || '', e.target.value)} - /> -
- ) - return MockField - } - return { default: MockBaseFieldFactory } -}) - -// Mock useAppForm -const mockHandleSubmit = vi.fn() -const mockFormValues: Record = {} -vi.mock('@/app/components/base/form', () => ({ - useAppForm: (options: { - validators?: { onSubmit?: (arg: { value: Record }) => unknown } - onSubmit?: (arg: { value: Record }) => void - }) => { - const formOptions = options - return { - handleSubmit: () => { - const validationResult = formOptions.validators?.onSubmit?.({ value: mockFormValues }) - if (!validationResult) { - mockHandleSubmit() - formOptions.onSubmit?.({ value: mockFormValues }) - } - }, - getFieldValue: (field: string) => mockFormValues[field], - setFieldValue: (field: string, value: unknown) => { - mockFormValues[field] = value - }, - } - }, -})) - -const createMockVariable = ( - overrides?: Partial, -): RAGPipelineVariables[0] => ({ - belong_to_node_id: 'node-1', - type: PipelineInputVarType.textInput, - label: 'Test Label', - variable: 'test_variable', - max_length: 100, - default_value: '', - placeholder: 'Enter value', - required: true, - ...overrides, -}) - -const createMockVariables = (count = 1): RAGPipelineVariables => { - return Array.from({ length: count }, (_, i) => - createMockVariable({ - variable: `variable_${i}`, - label: `Label ${i}`, - }), +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn() } })) +const variables = [ + createPipelineVariable({ variable: 'url', label: 'URL', default_value: 'https://example.com' }), +] +const runName = 'datasetCreation.stepOne.website.run' + +it('rejects an empty required field and runs with the corrected URL', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render() + const input = screen.getByRole('textbox', { name: /URL/ }) + expect(input).toHaveValue('https://example.com') + await user.clear(input) + await user.click(screen.getByRole('button', { name: runName })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('"url" URL is required')) + expect(onSubmit).not.toHaveBeenCalled() + await user.type(input, 'https://example.com/docs') + await user.click(screen.getByRole('button', { name: runName })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ url: 'https://example.com/docs' }), ) -} - -type MockConfiguration = { - type: BaseFieldType - variable: string - label: string - required: boolean - maxLength: number - options: unknown[] - showConditions: unknown[] - placeholder: string -} - -const createMockConfiguration = (overrides?: Partial): MockConfiguration => ({ - type: BaseFieldType.textInput, - variable: 'test_variable', - label: 'Test Label', - required: true, - maxLength: 100, - options: [], - showConditions: [], - placeholder: 'Enter value', - ...overrides, }) -type OptionsProps = React.ComponentProps - -const createDefaultProps = (overrides?: Partial): OptionsProps => ({ - variables: createMockVariables(), - step: CrawlStep.init, - runDisabled: false, - onSubmit: vi.fn(), - ...overrides, +it('preserves edited options across crawling and reopens them for another run', async () => { + const user = userEvent.setup() + const props = { variables, onSubmit: vi.fn() } + const { rerender } = render() + const input = screen.getByRole('textbox', { name: /URL/ }) + await user.clear(input) + await user.type(input, 'https://example.com/changed') + rerender() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'datasetCreation.stepOne.website.running' })) + expect(props.onSubmit).not.toHaveBeenCalled() + rerender() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + rerender() + expect(screen.getByRole('textbox', { name: /URL/ })).toHaveValue('https://example.com/changed') }) -const getRunButton = () => screen.getByRole('button', { name: /run/i }) - -describe('Options', () => { - beforeEach(() => { - vi.clearAllMocks() - mockToastError.mockReset() - - // Reset mock form values - Object.keys(mockFormValues).forEach((key) => delete mockFormValues[key]) - - // Default mock return values - using real generateZodSchema - mockUseInitialData.mockReturnValue({}) - mockUseConfigurations.mockReturnValue([createMockConfiguration()]) - }) - - describe('Rendering', () => { - it('should render options header with toggle text', () => { - const props = createDefaultProps() - - render() - - expect(screen.getByText(/options/i)).toBeInTheDocument() - }) - - it('should render Run button', () => { - const props = createDefaultProps() - - render() - - expect(getRunButton()).toBeInTheDocument() - expect(screen.getByText(/run/i)).toBeInTheDocument() - }) - - it('should render form fields when not folded', () => { - const configurations = [ - createMockConfiguration({ variable: 'url', label: 'URL' }), - createMockConfiguration({ variable: 'depth', label: 'Depth' }), - ] - mockUseConfigurations.mockReturnValue(configurations) - const props = createDefaultProps() - - render() - - expect(screen.getByTestId('field-url')).toBeInTheDocument() - expect(screen.getByTestId('field-depth')).toBeInTheDocument() - }) - }) - - describe('Props', () => { - describe('variables prop', () => { - it('should pass variables to useInitialData hook', () => { - const variables = createMockVariables(3) - const props = createDefaultProps({ variables }) - - render() - - expect(mockUseInitialData).toHaveBeenCalledWith(variables) - }) - - it('should pass variables to useConfigurations hook', () => { - const variables = createMockVariables(2) - const props = createDefaultProps({ variables }) - - render() - - expect(mockUseConfigurations).toHaveBeenCalledWith(variables) - }) - - it('should render correct number of fields based on configurations', () => { - const configurations = [ - createMockConfiguration({ variable: 'field_1', label: 'Field 1' }), - createMockConfiguration({ variable: 'field_2', label: 'Field 2' }), - createMockConfiguration({ variable: 'field_3', label: 'Field 3' }), - ] - mockUseConfigurations.mockReturnValue(configurations) - const props = createDefaultProps() - - render() - - expect(screen.getByTestId('field-field_1')).toBeInTheDocument() - expect(screen.getByTestId('field-field_2')).toBeInTheDocument() - expect(screen.getByTestId('field-field_3')).toBeInTheDocument() - }) - - it('should handle empty variables array', () => { - mockUseConfigurations.mockReturnValue([]) - const props = createDefaultProps({ variables: [] }) - - const { container } = render() - - expect(container.querySelector('form')).toBeInTheDocument() - expect(screen.queryByTestId(/field-/)).not.toBeInTheDocument() - }) - }) - - describe('step prop', () => { - it('should show "Run" text when step is init', () => { - const props = createDefaultProps({ step: CrawlStep.init }) - - render() - - expect(screen.getByText(/run/i)).toBeInTheDocument() - }) - - it('should show "Running" text when step is running', () => { - const props = createDefaultProps({ step: CrawlStep.running }) - - render() - - expect(screen.getByRole('button', { name: /running/i })).toBeInTheDocument() - }) - - it('should keep button loading-disabled when step is running', () => { - const props = createDefaultProps({ step: CrawlStep.running }) - - render() - - expectLoadingButton(getRunButton()) - }) - - it('should enable button when step is finished', () => { - const props = createDefaultProps({ step: CrawlStep.finished, runDisabled: false }) - - render() - - expect(getRunButton()).not.toBeDisabled() - }) - }) - - describe('runDisabled prop', () => { - it('should disable button when runDisabled is true', () => { - const props = createDefaultProps({ runDisabled: true }) - - render() - - expect(getRunButton()).toBeDisabled() - }) - - it('should enable button when runDisabled is false and step is not running', () => { - const props = createDefaultProps({ runDisabled: false, step: CrawlStep.init }) - - render() - - expect(getRunButton()).not.toBeDisabled() - }) - - it('should disable button when both runDisabled is true and step is running', () => { - const props = createDefaultProps({ runDisabled: true, step: CrawlStep.running }) - - render() - - expectLoadingButton(getRunButton()) - }) - - it('should default runDisabled to undefined (falsy)', () => { - const props = createDefaultProps() - delete (props as Partial).runDisabled - - render() - - expect(getRunButton()).not.toBeDisabled() - }) - }) - - describe('onSubmit prop', () => { - it('should call onSubmit when form is submitted successfully', () => { - // Arrange - Use non-required field so validation passes - const config = createMockConfiguration({ - variable: 'optional_field', - required: false, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([config]) - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - - render() - fireEvent.click(getRunButton()) - - expect(mockOnSubmit).toHaveBeenCalled() - }) - - it('should not call onSubmit when validation fails', () => { - const mockOnSubmit = vi.fn() - // Create a required field configuration - const requiredConfig = createMockConfiguration({ - variable: 'url', - label: 'URL', - required: true, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([requiredConfig]) - // mockFormValues is empty, so required field validation will fail - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - - render() - fireEvent.click(getRunButton()) - - expect(mockOnSubmit).not.toHaveBeenCalled() - }) - - it('should pass form values to onSubmit', () => { - // Arrange - Use non-required fields so validation passes - const configs = [ - createMockConfiguration({ - variable: 'url', - required: false, - type: BaseFieldType.textInput, - }), - createMockConfiguration({ - variable: 'depth', - required: false, - type: BaseFieldType.numberInput, - }), - ] - mockUseConfigurations.mockReturnValue(configs) - mockFormValues.url = 'https://example.com' - mockFormValues.depth = 2 - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - - render() - fireEvent.click(getRunButton()) - - expect(mockOnSubmit).toHaveBeenCalledWith({ url: 'https://example.com', depth: 2 }) - }) - }) - }) - - // Side Effects and Cleanup (useEffect) - describe('Side Effects and Cleanup', () => { - it('should expand options when step changes to init', () => { - const props = createDefaultProps({ step: CrawlStep.finished }) - const { rerender } = render() - - // Act - Change step to init - rerender() - - // Assert - Fields should be visible (expanded) - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - }) - - it('should collapse options when step changes to running', () => { - const props = createDefaultProps({ step: CrawlStep.init }) - const { rerender } = render() - - // Assert - Initially expanded - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - - // Act - Change step to running - rerender() - - // Assert - Should collapse (fields hidden, arrow rotated) - expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() - }) - - it('should collapse options when step changes to finished', () => { - const props = createDefaultProps({ step: CrawlStep.init }) - const { rerender } = render() - - // Act - Change step to finished - rerender() - - // Assert - Should collapse - expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() - }) - - it('should respond to step transitions from init -> running -> finished', () => { - const props = createDefaultProps({ step: CrawlStep.init }) - const { rerender } = render() - - // Assert - Initially expanded - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - - // Act - Transition to running - rerender() - - // Assert - Collapsed - expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() - - // Act - Transition to finished - rerender() - - // Assert - Still collapsed - expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() - }) - - it('should expand when step transitions from finished to init', () => { - const props = createDefaultProps({ step: CrawlStep.finished }) - const { rerender } = render() - - // Assert - Initially collapsed when finished - expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() - - // Act - Transition back to init - rerender() - - // Assert - Should expand - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - }) - }) - - // Memoization Logic and Dependencies - describe('Memoization Logic and Dependencies', () => { - it('should regenerate schema when configurations change', () => { - const config1 = [createMockConfiguration({ variable: 'url' })] - const config2 = [createMockConfiguration({ variable: 'depth' })] - mockUseConfigurations.mockReturnValue(config1) - const props = createDefaultProps() - const { rerender } = render() - - // Assert - First render creates schema - expect(screen.getByTestId('field-url')).toBeInTheDocument() - - // Act - Change configurations - mockUseConfigurations.mockReturnValue(config2) - rerender() - - // Assert - New field is rendered with new schema - expect(screen.getByTestId('field-depth')).toBeInTheDocument() - }) - - it('should compute isRunning correctly for init step', () => { - const props = createDefaultProps({ step: CrawlStep.init }) - - render() - - // Assert - Button should not be in loading state - const button = getRunButton() - expect(button).not.toBeDisabled() - expect(screen.getByText(/run/i)).toBeInTheDocument() - }) - - it('should compute isRunning correctly for running step', () => { - const props = createDefaultProps({ step: CrawlStep.running }) - - render() - - const button = getRunButton() - expectLoadingButton(button) - expect(screen.getByText(/running/i)).toBeInTheDocument() - }) - - it('should compute isRunning correctly for finished step', () => { - const props = createDefaultProps({ step: CrawlStep.finished }) - - render() - - // Assert - Button should not be in loading state - expect(screen.getByText(/run/i)).toBeInTheDocument() - }) - - it('should use memoized schema for validation', () => { - // Arrange - Use real generateZodSchema with valid configuration - const config = createMockConfiguration({ - variable: 'test_field', - required: false, // Not required so validation passes with empty value - }) - mockUseConfigurations.mockReturnValue([config]) - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - render() - - // Act - Trigger validation via submit - fireEvent.click(getRunButton()) - - // Assert - onSubmit should be called if validation passes - expect(mockOnSubmit).toHaveBeenCalled() - }) - }) - - // User Interactions and Event Handlers - describe('User Interactions and Event Handlers', () => { - it('should toggle fold state when header is clicked', () => { - const props = createDefaultProps() - render() - - // Assert - Initially expanded - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - - // Act - Click to fold - fireEvent.click(screen.getByText(/options/i)) - - // Assert - Should be folded - expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() - - // Act - Click to unfold - fireEvent.click(screen.getByText(/options/i)) - - // Assert - Should be expanded again - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - }) - - it('should prevent default and stop propagation on form submit', () => { - const props = createDefaultProps() - const { container } = render() - - const form = container.querySelector('form')! - const mockPreventDefault = vi.fn() - const mockStopPropagation = vi.fn() - - fireEvent.submit(form, { - preventDefault: mockPreventDefault, - stopPropagation: mockStopPropagation, - }) - - // Assert - The form element handles submit event - expect(form).toBeInTheDocument() - }) - - it('should trigger form submit when button is clicked', () => { - // Arrange - Use non-required field so validation passes - const config = createMockConfiguration({ - variable: 'optional_field', - required: false, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([config]) - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - render() - - fireEvent.click(getRunButton()) - - expect(mockOnSubmit).toHaveBeenCalled() - }) - - it('should not trigger submit when button is disabled', () => { - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit, runDisabled: true }) - render() - - // Act - Try to click disabled button - fireEvent.click(getRunButton()) - - expect(mockOnSubmit).not.toHaveBeenCalled() - }) - - it('should maintain fold state after form submission', () => { - const props = createDefaultProps() - render() - - // Assert - Initially expanded - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - - // Act - Submit form - fireEvent.click(getRunButton()) - - // Assert - Should still be expanded (unless step changes) - expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() - }) - }) - - describe('Edge Cases and Error Handling', () => { - it('should handle validation error and show toast', () => { - // Arrange - Create required field that will fail validation when empty - const requiredConfig = createMockConfiguration({ - variable: 'url', - label: 'URL', - required: true, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([requiredConfig]) - // mockFormValues.url is undefined, so validation will fail - const props = createDefaultProps() - render() - - fireEvent.click(getRunButton()) - - // Assert - Toast should be called with error message - expect(mockToastError).toHaveBeenCalled() - }) - - it('should handle validation error and display field name in message', () => { - // Arrange - Create required field that will fail validation - const requiredConfig = createMockConfiguration({ - variable: 'email_address', - label: 'Email Address', - required: true, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([requiredConfig]) - const props = createDefaultProps() - render() - - fireEvent.click(getRunButton()) - - // Assert - Toast message should contain field path - expect(mockToastError).toHaveBeenCalledWith(expect.stringContaining('email_address')) - }) - - it('should handle empty variables gracefully', () => { - mockUseConfigurations.mockReturnValue([]) - const props = createDefaultProps({ variables: [] }) - - const { container } = render() - - // Assert - Should render without errors - expect(container.querySelector('form')).toBeInTheDocument() - expect(getRunButton()).toBeInTheDocument() - }) - - it('should handle single variable configuration', () => { - const singleConfig = [createMockConfiguration({ variable: 'only_field' })] - mockUseConfigurations.mockReturnValue(singleConfig) - const props = createDefaultProps() - - render() - - expect(screen.getByTestId('field-only_field')).toBeInTheDocument() - }) - - it('should handle many configurations', () => { - const manyConfigs = Array.from({ length: 10 }, (_, i) => - createMockConfiguration({ variable: `field_${i}`, label: `Field ${i}` }), - ) - mockUseConfigurations.mockReturnValue(manyConfigs) - const props = createDefaultProps() - - render() - - for (let i = 0; i < 10; i++) - expect(screen.getByTestId(`field-field_${i}`)).toBeInTheDocument() - }) - - it('should handle validation with multiple required fields (shows first error)', () => { - // Arrange - Multiple required fields - const configs = [ - createMockConfiguration({ - variable: 'url', - label: 'URL', - required: true, - type: BaseFieldType.textInput, - }), - createMockConfiguration({ - variable: 'depth', - label: 'Depth', - required: true, - type: BaseFieldType.textInput, - }), - ] - mockUseConfigurations.mockReturnValue(configs) - const props = createDefaultProps() - render() - - fireEvent.click(getRunButton()) - - // Assert - Toast should be called once (only first error) - expect(mockToastError).toHaveBeenCalledTimes(1) - expect(mockToastError).toHaveBeenCalled() - }) - - it('should handle validation pass when all required fields have values', () => { - const requiredConfig = createMockConfiguration({ - variable: 'url', - label: 'URL', - required: true, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([requiredConfig]) - mockFormValues.url = 'https://example.com' // Provide valid value - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - render() - - fireEvent.click(getRunButton()) - - // Assert - No toast error, onSubmit called - expect(mockToastError).not.toHaveBeenCalled() - expect(mockOnSubmit).toHaveBeenCalled() - }) - - it('should handle undefined variables gracefully', () => { - mockUseInitialData.mockReturnValue({}) - mockUseConfigurations.mockReturnValue([]) - const props = createDefaultProps({ variables: undefined as unknown as RAGPipelineVariables }) - - // Act & Assert - Should not throw - expect(() => render()).not.toThrow() - }) - - it('should handle rapid fold/unfold toggling', () => { - const props = createDefaultProps() - render() - - // Act - Toggle rapidly multiple times - const toggleText = screen.getByText(/options/i) - for (let i = 0; i < 5; i++) fireEvent.click(toggleText) - - // Assert - Final state should be folded (odd number of clicks) - expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() - }) - }) - - // All Prop Variations - describe('Prop Variations', () => { - it.each([ - [{ step: CrawlStep.init, runDisabled: false }, false, 'run'], - [{ step: CrawlStep.init, runDisabled: true }, true, 'run'], - [{ step: CrawlStep.running, runDisabled: false }, true, 'running'], - [{ step: CrawlStep.running, runDisabled: true }, true, 'running'], - [{ step: CrawlStep.finished, runDisabled: false }, false, 'run'], - [{ step: CrawlStep.finished, runDisabled: true }, true, 'run'], - ] as const)( - 'should render correctly with step=%s, runDisabled=%s', - (propVariation, expectedDisabled, expectedText) => { - const props = createDefaultProps(propVariation) - - render() - - const button = getRunButton() - if (propVariation.step === CrawlStep.running) expectLoadingButton(button) - else if (expectedDisabled) expect(button).toBeDisabled() - else expect(button).not.toBeDisabled() - - expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument() - }, - ) - - it('should handle all CrawlStep values', () => { - // Arrange & Act & Assert - Object.values(CrawlStep).forEach((step) => { - const props = createDefaultProps({ step }) - const { unmount, container } = render() - expect(container.querySelector('form')).toBeInTheDocument() - unmount() - }) - }) - - it('should handle variables with different types', () => { - const variables: RAGPipelineVariables = [ - createMockVariable({ type: PipelineInputVarType.textInput, variable: 'text_field' }), - createMockVariable({ type: PipelineInputVarType.paragraph, variable: 'paragraph_field' }), - createMockVariable({ type: PipelineInputVarType.number, variable: 'number_field' }), - createMockVariable({ type: PipelineInputVarType.checkbox, variable: 'checkbox_field' }), - createMockVariable({ type: PipelineInputVarType.select, variable: 'select_field' }), - ] - const configurations = variables.map((v) => createMockConfiguration({ variable: v.variable })) - mockUseConfigurations.mockReturnValue(configurations) - const props = createDefaultProps({ variables }) - - render() - - variables.forEach((v) => { - expect(screen.getByTestId(`field-${v.variable}`)).toBeInTheDocument() - }) - }) - }) - - // Form Validation - describe('Form Validation', () => { - it('should pass validation with valid data', () => { - // Arrange - Use non-required field so empty value passes - const config = createMockConfiguration({ - variable: 'optional_field', - required: false, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([config]) - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - render() - - fireEvent.click(getRunButton()) - - expect(mockOnSubmit).toHaveBeenCalled() - expect(mockToastError).not.toHaveBeenCalled() - }) - - it('should fail validation with invalid data', () => { - // Arrange - Required field with empty value - const config = createMockConfiguration({ - variable: 'url', - label: 'URL', - required: true, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([config]) - const mockOnSubmit = vi.fn() - const props = createDefaultProps({ onSubmit: mockOnSubmit }) - render() - - fireEvent.click(getRunButton()) - - expect(mockOnSubmit).not.toHaveBeenCalled() - expect(mockToastError).toHaveBeenCalled() - }) - - it('should show error toast message when validation fails', () => { - // Arrange - Required field with empty value - const config = createMockConfiguration({ - variable: 'my_field', - label: 'My Field', - required: true, - type: BaseFieldType.textInput, - }) - mockUseConfigurations.mockReturnValue([config]) - const props = createDefaultProps() - render() - - fireEvent.click(getRunButton()) - - expect(mockToastError).toHaveBeenCalledWith(expect.any(String)) - }) - }) - - // Styling Tests - describe('Styling', () => { - it('should apply select-none class to prevent text selection on toggle', () => { - const props = createDefaultProps() - - const { container } = render() - - const toggleContainer = container.querySelector('.select-none') - expect(toggleContainer).toBeInTheDocument() - }) - - it('should apply border class to fields container when expanded', () => { - const props = createDefaultProps() - - const { container } = render() - - const fieldsContainer = container.querySelector('.border-t') - expect(fieldsContainer).toBeInTheDocument() - }) - }) - - // BaseField Integration - describe('BaseField Integration', () => { - it('should pass correct props to BaseField factory', () => { - const config = createMockConfiguration({ variable: 'test_var', label: 'Test Label' }) - mockUseConfigurations.mockReturnValue([config]) - mockUseInitialData.mockReturnValue({ test_var: 'default_value' }) - const props = createDefaultProps() - - render() - - expect(mockBaseField).toHaveBeenCalledWith( - expect.objectContaining({ - initialData: { test_var: 'default_value' }, - config, - }), - ) - }) - - it('should render unique key for each field', () => { - const configurations = [ - createMockConfiguration({ variable: 'field_a' }), - createMockConfiguration({ variable: 'field_b' }), - createMockConfiguration({ variable: 'field_c' }), - ] - mockUseConfigurations.mockReturnValue(configurations) - const props = createDefaultProps() - - render() - - // Assert - All fields should be rendered (React would warn if keys aren't unique) - expect(screen.getByTestId('field-field_a')).toBeInTheDocument() - expect(screen.getByTestId('field-field_b')).toBeInTheDocument() - expect(screen.getByTestId('field-field_c')).toBeInTheDocument() - }) - }) +it('does not start a crawl when the caller disables running', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render() + await user.click(screen.getByRole('button', { name: runName })) + expect(onSubmit).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts index 13c3559fcbec5d..0394ca0a388a62 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts @@ -1,19 +1,8 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { Node } from '@/app/components/workflow/types' import { renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vite-plus/test' - -vi.mock('@/app/components/workflow/types', async () => { - const actual = await vi.importActual>('@/app/components/workflow/types') - const blockEnum = actual.BlockEnum as Record - return { - ...actual, - BlockEnum: { - ...blockEnum, - DataSource: 'data-source', - }, - } -}) +import { describe, expect, it } from 'vite-plus/test' +import { BlockEnum } from '@/app/components/workflow/types' const { useDatasourceOptions } = await import('../use-datasource-options') @@ -37,8 +26,8 @@ describe('useDatasourceOptions', () => { it('should return options for datasource nodes', () => { const nodes = [ - createNode('n1', 'File Upload', 'data-source'), - createNode('n2', 'Web Crawl', 'data-source'), + createNode('n1', 'File Upload', BlockEnum.DataSource), + createNode('n2', 'Web Crawl', BlockEnum.DataSource), createNode('n3', 'LLM Node', 'llm'), ] const { result } = renderHook(() => useDatasourceOptions(nodes)) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx index c7fa957196f37a..ba546d2fc94fd5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx @@ -1,6 +1,9 @@ import type { NotionPage } from '@/models/common' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import OnlineDocumentPreview from '../online-document-preview' // Uses global react-i18next mock from web/vitest.setup.ts @@ -32,13 +35,17 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock usePreviewOnlineDocument hook - needs mock to control mutation behavior const mockMutateAsync = vi.fn() -const mockUsePreviewOnlineDocument = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post': () => - mockUsePreviewOnlineDocument(), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post': ( + input: unknown, + ) => mockMutateAsync(input), + }), + } }) // Mock data source store - needs mock to control store state @@ -73,298 +80,87 @@ const defaultProps = { describe('OnlineDocumentPreview', () => { beforeEach(() => { vi.clearAllMocks() - mockToastError.mockReset() mockPipelineId.mockReturnValue('pipeline-123') - mockUsePreviewOnlineDocument.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: false, - }) - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onSuccess({ content: 'Test content' }) - return Promise.resolve({ content: 'Test content' }) - }) + mockGetState.mockReturnValue({ currentCredentialId: mockCurrentCredentialId }) + mockMutateAsync.mockReset().mockResolvedValue({ content: 'Test content' }) }) - describe('Rendering', () => { - it('should render the component with page information', () => { - render() - - // i18n mock returns key by default - expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() - expect(screen.getByText('Test Notion Page')).toBeInTheDocument() - }) - - it('should display page type', () => { - const currentPage = createMockNotionPage({ type: 'database' }) - - render() - - expect(screen.getByText('database')).toBeInTheDocument() - }) - - it('should render close button', () => { - render() - - expect(screen.getByRole('button')).toBeInTheDocument() + it('requests the current page using the pipeline, node, workspace and credential', async () => { + mockGetState.mockReturnValue({ currentCredentialId: 'custom-credential' }) + render() + expect(await screen.findByText('Test content')).toBeInTheDocument() + expect(screen.getByText('Test Notion Page')).toBeInTheDocument() + expect(screen.getByText('page')).toBeInTheDocument() + expect(mockMutateAsync).toHaveBeenCalledWith({ + params: { pipeline_id: 'pipeline-123', node_id: 'datasource-node-123' }, + body: { + datasource_type: 'online_document', + credential_id: 'custom-credential', + inputs: { workspace_id: 'workspace-456', page_id: 'page-123', type: 'page' }, + }, }) }) - describe('Data Fetching', () => { - it('should call mutateAsync with correct parameters on mount', async () => { - const currentPage = createMockNotionPage({ - workspace_id: 'ws-123', - page_id: 'pg-456', - type: 'page', - }) - - render( - , - ) - - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith( - { - params: { pipeline_id: 'pipeline-123', node_id: 'node-789' }, - body: { - datasource_type: 'online_document', - credential_id: mockCurrentCredentialId, - inputs: { workspace_id: 'ws-123', page_id: 'pg-456', type: 'page' }, - }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), - ) - }) - }) - - it('should fetch data again when page_id changes', async () => { - const currentPage1 = createMockNotionPage({ page_id: 'page-1' }) - const currentPage2 = createMockNotionPage({ page_id: 'page-2' }) - - const { rerender } = render( - , - ) - - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledTimes(1) - }) - - rerender() - - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledTimes(2) - }) - }) - - it('should handle empty pipelineId', async () => { - mockPipelineId.mockReturnValue(undefined) - - render() - - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ pipeline_id: '' }), - }), - expect.anything(), - ) - }) - }) - }) - - describe('Loading State', () => { - it('should render loading component when isPending is true', () => { - mockUsePreviewOnlineDocument.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: true, - }) - - render() - - // Loading component renders skeleton - expect(document.querySelector('.overflow-hidden')).toBeInTheDocument() - }) - - it('should not render markdown content when loading', () => { - mockUsePreviewOnlineDocument.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: true, - }) - - render() - - // Content area should not be present - expect(screen.queryByText('Test content')).not.toBeInTheDocument() - }) - }) - - describe('Content Display', () => { - it('should render markdown content when loaded', async () => { - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onSuccess({ content: 'Markdown content here' }) - return Promise.resolve({ content: 'Markdown content here' }) - }) - - render() - - await waitFor(() => { - // Markdown component renders the content - const contentArea = document.querySelector('.overflow-hidden.px-6.py-5') - expect(contentArea).toBeInTheDocument() - }) - }) - - it('should display character count', async () => { - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onSuccess({ content: 'Hello' }) // 5 characters - return Promise.resolve({ content: 'Hello' }) - }) - - render() - - await waitFor(() => { - // Real formatNumberAbbreviated returns "5" for numbers < 1000 - expect(screen.getByText(/5/)).toBeInTheDocument() - }) - }) - - it('should format large character counts', async () => { - const longContent = 'a'.repeat(2500) - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onSuccess({ content: longContent }) - return Promise.resolve({ content: longContent }) - }) - - render() - - await waitFor(() => { - // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" - expect(screen.getByText(/2\.5k/)).toBeInTheDocument() - }) - }) - - it('should show character count based on fetched content', async () => { - // When content is set via onSuccess, character count is displayed - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onSuccess({ content: 'Test content' }) // 12 characters - return Promise.resolve({ content: 'Test content' }) - }) - - render() - - await waitFor(() => { - expect(screen.getByText(/12/)).toBeInTheDocument() - }) - }) + it('keeps content hidden until the request resolves and shows its character count', async () => { + const request = createDeferred<{ content: string }>() + mockMutateAsync.mockReturnValue(request.promise) + render() + await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce()) + expect(screen.queryByText('Hello')).not.toBeInTheDocument() + await act(async () => request.resolve({ content: 'Hello' })) + expect(await screen.findByText('Hello')).toBeInTheDocument() + expect(screen.getByText('5 datasetPipeline.addDocuments.characters')).toBeInTheDocument() }) - describe('Error Handling', () => { - it('should show toast notification on error', async () => { - const errorMessage = 'Failed to fetch document' - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onError(new Error(errorMessage)) - // Return a resolved promise to avoid unhandled rejection - return Promise.resolve() - }) - - render() - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith(errorMessage) - }) - }) - - it('should handle network errors', async () => { - const networkError = new Error('Network Error') - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onError(networkError) - // Return a resolved promise to avoid unhandled rejection - return Promise.resolve() - }) - - render() - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith('Network Error') - }) - }) + it('loads the newly selected page without reusing the previous response', async () => { + const user = userEvent.setup() + const { rerender } = render() + await screen.findByText('Test content') + mockMutateAsync.mockResolvedValue({ content: 'New page content' }) + rerender( + , + ) + expect(await screen.findByText('New page content')).toBeInTheDocument() + expect(screen.queryByText('Test content')).not.toBeInTheDocument() + expect(mockMutateAsync).toHaveBeenCalledTimes(2) + expect(mockMutateAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + inputs: expect.objectContaining({ page_id: 'page-2', type: 'database' }), + }), + }), + ) + await user.click(screen.getByRole('button')) + expect(defaultProps.hidePreview).toHaveBeenCalledOnce() }) - describe('User Interactions', () => { - it('should call hidePreview when close button is clicked', () => { - const hidePreview = vi.fn() - - render() - - // Find the close button in the header area (not toast buttons) - const headerArea = document.querySelector('.flex.gap-x-2.border-b') - const closeButton = headerArea?.querySelector('button') - expect(closeButton).toBeInTheDocument() - fireEvent.click(closeButton!) - - expect(hidePreview).toHaveBeenCalledTimes(1) - }) + it('formats large character counts from the response', async () => { + mockMutateAsync.mockResolvedValue({ content: 'a'.repeat(2500) }) + render() + expect( + await screen.findByText('2.5k datasetPipeline.addDocuments.characters'), + ).toBeInTheDocument() }) - describe('Edge Cases', () => { - it('should handle undefined page_name', () => { - const currentPage = createMockNotionPage({ page_name: '' }) - - render() - - // Find the close button in the header area - const headerArea = document.querySelector('.flex.gap-x-2.border-b') - const closeButton = headerArea?.querySelector('button') - expect(closeButton).toBeInTheDocument() - }) - - it('should handle different page types', () => { - const currentPage = createMockNotionPage({ type: 'database' }) - - render() - - expect(screen.getByText('database')).toBeInTheDocument() - }) - - it('should use credentialId from store', async () => { - mockGetState.mockReturnValue({ - currentCredentialId: 'custom-credential', - }) - + it.each(['Failed to fetch document', 'Network Error'])( + 'reports a rejected request without an unhandled rejection: %s', + async (message) => { + mockMutateAsync.mockRejectedValue(new Error(message)) render() - - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ credential_id: 'custom-credential' }), - }), - expect.anything(), - ) - }) - }) - - it('should not render markdown content when content is empty and not pending', async () => { - mockMutateAsync.mockImplementation((params, callbacks) => { - callbacks.onSuccess({ content: '' }) - return Promise.resolve({ content: '' }) - }) - mockUsePreviewOnlineDocument.mockReturnValue({ - mutateAsync: mockMutateAsync, - isPending: false, - }) - - render() - - // Content is empty, markdown area should still render but be empty - await waitFor(() => { - expect(screen.queryByText('Test content')).not.toBeInTheDocument() - }) - }) + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith(message)) + expect(screen.queryByText('Test content')).not.toBeInTheDocument() + }, + ) + + it('keeps the empty response empty after the mutation finishes', async () => { + mockMutateAsync.mockResolvedValue({ content: '' }) + const { queryClient } = render() + await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce()) + await waitFor(() => expect(queryClient.isMutating()).toBe(0)) + expect(screen.getByText('0 datasetPipeline.addDocuments.characters')).toBeInTheDocument() + expect(screen.queryByText('Test content')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx index af5348f0590bb8..bdb359439b3775 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx @@ -29,7 +29,7 @@ const OnlineDocumentPreview = ({ const { t } = useTranslation(['datasetPipeline']) const [content, setContent] = useState('') const pipelineId = useDatasetDetailContextWithSelector((state) => state.dataset?.pipeline_id) - const { mutateAsync: getOnlineDocumentContent, isPending } = useMutation( + const { mutate: getOnlineDocumentContent, isPending } = useMutation( consoleQuery.rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post.mutationOptions(), ) const dataSourceStore = useDataSourceStore() diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts index 15f3f812e223d6..82c8e1d10d0755 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts @@ -1,62 +1,61 @@ -import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { act, waitFor } from '@testing-library/react' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { useInputVariables } from '../hooks' -const mockUseDatasetDetailContextWithSelector = vi.fn() -const mockUsePublishedPipelineProcessingParams = vi.fn() - +const { getParams } = vi.hoisted(() => ({ getParams: vi.fn() })) vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (value: unknown) => unknown) => - mockUseDatasetDetailContextWithSelector(selector), + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: { pipeline_id: string } }) => unknown, + ) => selector({ dataset: { pipeline_id: 'pipeline-1' } }), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': (o: { - input: { params: { pipeline_id: string }; query: { node_id: string } } - enabled?: boolean - }) => - mockUsePublishedPipelineProcessingParams({ - pipeline_id: o.input.params.pipeline_id, - node_id: o.input.query.node_id, - }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': getParams, + }), + } }) +const response = { + variables: [ + { + variable: 'chunkSize', + label: 'Chunk size', + type: 'number', + required: true, + default_value: 100, + }, + ], +} +const renderVariables = () => renderHook(() => useInputVariables('node-1')) + describe('useInputVariables', () => { beforeEach(() => { vi.clearAllMocks() - mockUseDatasetDetailContextWithSelector.mockReturnValue('pipeline-123') - mockUsePublishedPipelineProcessingParams.mockReturnValue({ - data: { inputs: [{ name: 'query', type: 'string' }] }, - isFetching: false, - }) - }) - - it('should return paramsConfig and isFetchingParams', () => { - const { result } = renderHook(() => useInputVariables('node-1')) - - expect(result.current.paramsConfig).toEqual({ inputs: [{ name: 'query', type: 'string' }] }) - expect(result.current.isFetchingParams).toBe(false) + getParams.mockResolvedValue(response) }) - - it('should call usePublishedPipelineProcessingParams with pipeline_id and node_id', () => { - renderHook(() => useInputVariables('node-1')) - - expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({ - pipeline_id: 'pipeline-123', - node_id: 'node-1', + it('fetches processing parameters for the current pipeline and node', async () => { + const { result } = renderVariables() + await waitFor(() => expect(result.current.paramsConfig?.variables).toHaveLength(1)) + expect(result.current.paramsConfig?.variables[0]).toMatchObject(response.variables[0]!) + expect(getParams).toHaveBeenCalledWith({ + params: { pipeline_id: 'pipeline-1' }, + query: { node_id: 'node-1' }, }) + expect(result.current.isFetchingParams).toBe(false) }) - - it('should return isFetchingParams true when loading', () => { - mockUsePublishedPipelineProcessingParams.mockReturnValue({ - data: undefined, - isFetching: true, - }) - - const { result } = renderHook(() => useInputVariables('node-1')) + it('stays fetching until processing parameters resolve', async () => { + const pending = createDeferred() + getParams.mockReturnValue(pending.promise) + const { result } = renderVariables() expect(result.current.isFetchingParams).toBe(true) expect(result.current.paramsConfig).toBeUndefined() + await act(async () => pending.resolve(response)) + await waitFor(() => expect(result.current.isFetchingParams).toBe(false)) + expect(result.current.paramsConfig?.variables).toHaveLength(1) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 52972647bb361f..e464d5ff546d36 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -59,9 +59,6 @@ let mockIndexingStatusData: DocumentStatusResponse[] = [] vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.batch.byBatch.indexingStatus.get': () => ({ - mutateAsync: (...args: unknown[]) => mockFetchIndexingStatus(...args), - }), 'datasets.processRule.get': () => ({ data: { mode: 'custom', @@ -101,7 +98,7 @@ const createMockDocument = (overrides: Partial = {}): Pipeline const createMockIndexingStatus = ( overrides: Partial = {}, ): DocumentStatusResponse => ({ - id: `doc-${Math.random().toString(36).slice(2, 9)}`, + id: `status-${++documentIdCounter}`, indexing_status: 'waiting' as DocumentStatusResponse['indexing_status'], processing_started_at: Date.now(), parsing_completed_at: 0, @@ -165,11 +162,9 @@ describe('EmbeddingProcess', () => { mockIndexingStatusData = [] // Setup default mock for fetchIndexingStatus - mockFetchIndexingStatus = vi.fn().mockImplementation((_, options) => { - options?.onSuccess?.({ data: mockIndexingStatusData }) - options?.onSettled?.() - return Promise.resolve({ data: mockIndexingStatusData }) - }) + mockFetchIndexingStatus = vi + .fn() + .mockImplementation(() => Promise.resolve({ data: mockIndexingStatusData })) }) afterEach(() => { @@ -1088,3 +1083,15 @@ describe('EmbeddingProcess', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.batch.byBatch.indexingStatus.get': (input) => + mockFetchIndexingStatus(input), + }), + } +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx index 5d817f9043b3e5..be8d769ae3e86c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx @@ -12,11 +12,6 @@ import { StepOnePreview, StepTwoPreview } from '../preview-panel' vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post': - () => ({ - mutateAsync: vi.fn().mockResolvedValue({ content: 'Mock document content' }), - isPending: false, - }), 'files.byFileId.preview.get': vi.fn(() => ({ data: { content: 'Mock file content for testing' }, isFetching: false, @@ -275,3 +270,15 @@ describe('StepTwoPreview', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.workflows.published.datasource.nodes.byNodeId.preview.post': () => + Promise.resolve({ content: 'Mock document content' }), + }), + } +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index 3f9a7c05d14267..eff03c0d4807c4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -49,16 +49,6 @@ vi.mock('../../data-source/store', () => ({ })) // Mock file upload config -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - file_size_limit: 15 * 1024 * 1024, - batch_count_limit: 20, - document_file_extensions: ['.txt', '.md', '.pdf'], - }, - isLoading: false, - })), -})) // Mock hooks used by data source options vi.mock('../../hooks', () => ({ @@ -68,9 +58,6 @@ vi.mock('../../hooks', () => ({ })) // Mock useDatasourceIcon hook to avoid complex data source list transformation -vi.mock('../../data-source-options/hooks', () => ({ - useDatasourceIcon: vi.fn(() => '/icons/local-file.svg'), -})) // Mock the entire local-file component since it has deep context dependencies vi.mock('../../data-source/local-file', () => ({ diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index b0ab3e348ff7f2..f02b082884b714 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -69,10 +69,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { error: mocks.state.documentError, refetch: mocks.detailRefetch, }, - 'datasets.batchImportStatus.byJobId.get': () => ({ mutateAsync: mocks.checkProgress }), - 'datasets.byDatasetId.documents.byDocumentId.segments.batchImport.post': () => ({ - mutateAsync: mocks.batchImport, - }), }) }) @@ -288,6 +284,7 @@ vi.mock('@/context/permission-state', async () => { describe('DocumentDetail', () => { beforeEach(() => { vi.clearAllMocks() + mocks.checkProgress.mockResolvedValue({ job_id: 'job-1', job_status: 'completed' }) vi.spyOn(QueryClient.prototype, 'invalidateQueries') vi.useFakeTimers() mocks.state.dataset = { @@ -546,31 +543,17 @@ describe('DocumentDetail', () => { fireEvent.click(screen.getByTestId('batch-confirm')) }) - expect(mocks.batchImport).toHaveBeenCalledWith( - { - params: { dataset_id: 'ds-1', document_id: 'doc-1' }, - body: { upload_file_id: 'file-1' }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), - ) + expect(mocks.batchImport).toHaveBeenCalledWith({ + params: { dataset_id: 'ds-1', document_id: 'doc-1' }, + body: { upload_file_id: 'file-1' }, + }) }) - type JobCallbacks = { onSuccess: (job: { job_id: string; job_status: string }) => void } - it('should poll the import job until it finishes', async () => { - mocks.batchImport.mockImplementation((_input: unknown, callbacks: JobCallbacks) => - callbacks.onSuccess({ job_id: 'job-1', job_status: 'waiting' }), - ) + mocks.batchImport.mockResolvedValue({ job_id: 'job-1', job_status: 'waiting' }) mocks.checkProgress - .mockImplementationOnce((_input: unknown, callbacks: JobCallbacks) => - callbacks.onSuccess({ job_id: 'job-1', job_status: 'processing' }), - ) - .mockImplementationOnce((_input: unknown, callbacks: JobCallbacks) => - callbacks.onSuccess({ job_id: 'job-1', job_status: 'completed' }), - ) + .mockResolvedValueOnce({ job_id: 'job-1', job_status: 'processing' }) + .mockResolvedValueOnce({ job_id: 'job-1', job_status: 'completed' }) render() fireEvent.click(screen.getByTestId('batch-btn')) @@ -585,19 +568,12 @@ describe('DocumentDetail', () => { }) expect(mocks.checkProgress).toHaveBeenCalledTimes(2) - expect(mocks.checkProgress).toHaveBeenCalledWith( - { params: { job_id: 'job-1' } }, - expect.any(Object), - ) + expect(mocks.checkProgress).toHaveBeenCalledWith({ params: { job_id: 'job-1' } }) }) it('should stop polling when the job reports an unknown status', async () => { - mocks.batchImport.mockImplementation((_input: unknown, callbacks: JobCallbacks) => - callbacks.onSuccess({ job_id: 'job-1', job_status: 'waiting' }), - ) - mocks.checkProgress.mockImplementation((_input: unknown, callbacks: JobCallbacks) => - callbacks.onSuccess({ job_id: 'job-1', job_status: 'queued' }), - ) + mocks.batchImport.mockResolvedValue({ job_id: 'job-1', job_status: 'waiting' }) + mocks.checkProgress.mockResolvedValue({ job_id: 'job-1', job_status: 'queued' }) render() fireEvent.click(screen.getByTestId('batch-btn')) @@ -660,3 +636,16 @@ describe('DocumentDetail', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.batchImportStatus.byJobId.get': (input) => mocks.checkProgress(input), + 'datasets.byDatasetId.documents.byDocumentId.segments.batchImport.post': (input) => + mocks.batchImport(input), + }), + } +}) diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index e64e26f100710a..cfe7006abab63b 100644 --- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { toast } from '@/app/notifications' import { AppToastHost } from '@/app/notifications/host' import { ChunkingMode } from '@/models/datasets' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { IndexingType } from '../../../create/step-two' import NewSegmentModal from '../new-segment' @@ -43,13 +44,15 @@ vi.mock('../completed', () => ({ // Mock useAddSegment const mockAddSegment = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.byDocumentId.segment.post': () => ({ - mutateAsync: mockAddSegment, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.byDocumentId.segment.post': (input) => mockAddSegment(input), }), - }) + } }) vi.mock('../completed/common/action-buttons', () => ({ @@ -322,11 +325,7 @@ describe('NewSegmentModal', () => { // Successful save describe('Successful Save', () => { it('should call addSegment when valid content is provided for text mode', async () => { - mockAddSegment.mockImplementation((_params, options) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockResolvedValue(undefined) render() fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } }) @@ -341,17 +340,12 @@ describe('NewSegmentModal', () => { content: 'Valid content', }), }), - expect.any(Object), ) }) }) it('should show success notification after save', async () => { - mockAddSegment.mockImplementation((_params, options) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }) + mockAddSegment.mockResolvedValue(undefined) render() fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } }) @@ -452,13 +446,7 @@ describe('NewSegmentModal', () => { it('should call viewNewlyAddedChunk when the toast action is clicked', async () => { const mockViewNewlyAddedChunk = vi.fn() - mockAddSegment.mockImplementation( - (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }, - ) + mockAddSegment.mockResolvedValue(undefined) render( <> @@ -485,13 +473,7 @@ describe('NewSegmentModal', () => { describe('QA mode save with content', () => { it('should save with both question and answer in QA mode', async () => { - mockAddSegment.mockImplementation( - (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }, - ) + mockAddSegment.mockResolvedValue(undefined) render() @@ -511,7 +493,6 @@ describe('NewSegmentModal', () => { answer: 'My Answer', }), }), - expect.any(Object), ) }) }) @@ -520,13 +501,7 @@ describe('NewSegmentModal', () => { describe('Keywords in save params', () => { it('should include keywords in save params when keywords are provided', async () => { mockIndexingTechnique = IndexingType.ECONOMICAL - mockAddSegment.mockImplementation( - (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }, - ) + mockAddSegment.mockResolvedValue(undefined) render() @@ -548,7 +523,6 @@ describe('NewSegmentModal', () => { keywords: ['kw1', 'kw2'], }), }), - expect.any(Object), ) }) }) @@ -556,13 +530,7 @@ describe('NewSegmentModal', () => { describe('Save with attachments', () => { it('should include attachment_ids in save params when images are uploaded', async () => { - mockAddSegment.mockImplementation( - (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }, - ) + mockAddSegment.mockResolvedValue(undefined) render() @@ -584,7 +552,6 @@ describe('NewSegmentModal', () => { attachment_ids: ['img-1'], }), }), - expect.any(Object), ) }) }) @@ -593,13 +560,7 @@ describe('NewSegmentModal', () => { describe('handleCancel with addAnother unchecked', () => { it('should call onCancel when addAnother is unchecked and save succeeds', async () => { const mockOnCancel = vi.fn() - mockAddSegment.mockImplementation( - (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }, - ) + mockAddSegment.mockResolvedValue(undefined) render( , @@ -622,13 +583,7 @@ describe('NewSegmentModal', () => { describe('onSave after success', () => { it('should call onSave immediately after save succeeds', async () => { const mockOnSave = vi.fn() - mockAddSegment.mockImplementation( - (_params: unknown, options: { onSuccess: () => void; onSettled: () => void }) => { - options.onSuccess() - options.onSettled() - return Promise.resolve() - }, - ) + mockAddSegment.mockResolvedValue(undefined) render() diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index 23732ac3cad3a1..0326a4bf72d915 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -1,18 +1,13 @@ +import type { ReactElement } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' import { Theme } from '@/types/app' import CSVUploader from '../csv-uploader' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'files.upload.get': () => ({ - data: { file_size_limit: 15 }, - }), - }) -}) - // Mock upload service const mockUpload = vi.fn() vi.mock('@/service/base', () => ({ @@ -49,15 +44,14 @@ vi.mock('@/app/notifications', () => ({ toast: toastMocks.api, })) -vi.mock('use-context-selector', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - useContext: () => ({ - toast: toastMocks.api, - }), - } -}) +const render = (ui: ReactElement) => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData( + consoleQuery.files.upload.get.queryKey(), + createFileUploadConfigFixture(), + ) + return renderWithConsoleQuery(ui, { queryClient }) +} describe('CSVUploader', () => { beforeEach(() => { diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index 0af15e808a2eee..f77c7a7fa4601b 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -80,25 +80,6 @@ vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.byDocumentId.segments.get': () => ({ - isLoading: false, - data: mockSegmentListData, - }), - 'datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch': () => ({ - mutateAsync: mockOnChangeSwitch, - }), - 'datasets.byDatasetId.documents.byDocumentId.segments.delete': () => ({ - mutateAsync: mockOnDelete, - }), - 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch': () => ({ - mutateAsync: vi.fn(), - }), - }) -}) - const segmentListsKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.key({ input: { params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' } }, }) @@ -277,13 +258,22 @@ const _createMockChildChunk = ( const createQueryClient = () => new QueryClient({ defaultOptions: { - queries: { retry: false }, + queries: { retry: false, staleTime: Infinity }, mutations: { retry: false }, }, }) const createWrapper = () => { const queryClient = createQueryClient() + queryClient.setQueryData( + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.queryKey({ + input: { + params: { dataset_id: mockDatasetId.current, document_id: mockDocumentId.current }, + query: { page: 1, limit: 10, keyword: '', enabled: 'all' }, + }, + }), + { ...mockSegmentListData }, + ) return ({ children }: { children: React.ReactNode }) => ( {children} ) @@ -803,7 +793,7 @@ describe('Batch Action Callbacks', () => { const enableButton = screen.getByTestId('batch-enable') fireEvent.click(enableButton) - expect(mockOnChangeSwitch).toHaveBeenCalled() + await waitFor(() => expect(mockOnChangeSwitch).toHaveBeenCalled()) }) it('should call onChangeSwitch with false when batch disable is clicked', async () => { @@ -821,7 +811,7 @@ describe('Batch Action Callbacks', () => { const disableButton = screen.getByTestId('batch-disable') fireEvent.click(disableButton) - expect(mockOnChangeSwitch).toHaveBeenCalled() + await waitFor(() => expect(mockOnChangeSwitch).toHaveBeenCalled()) }) it('should call onDelete when batch delete is clicked', async () => { @@ -839,7 +829,7 @@ describe('Batch Action Callbacks', () => { const deleteButton = screen.getByTestId('batch-delete') fireEvent.click(deleteButton) - expect(mockOnDelete).toHaveBeenCalled() + await waitFor(() => expect(mockOnDelete).toHaveBeenCalled()) }) }) @@ -905,43 +895,6 @@ describe('refreshChunkListDataWithDetailChanged callback', () => { ) }) -// Batch Action Callback Coverage Tests - -describe('Batch Action callback simulation', () => { - // This test simulates the batch action callback behavior - // to ensure the arrow function callbacks are covered - - it('should simulate onBatchEnable callback behavior', () => { - const mockOnChangeSwitch = vi.fn() - - // Simulate the callback: () => segmentListDataHook.onChangeSwitch(true, '') - const onBatchEnable = () => mockOnChangeSwitch(true, '') - onBatchEnable() - - expect(mockOnChangeSwitch).toHaveBeenCalledWith(true, '') - }) - - it('should simulate onBatchDisable callback behavior', () => { - const mockOnChangeSwitch = vi.fn() - - // Simulate the callback: () => segmentListDataHook.onChangeSwitch(false, '') - const onBatchDisable = () => mockOnChangeSwitch(false, '') - onBatchDisable() - - expect(mockOnChangeSwitch).toHaveBeenCalledWith(false, '') - }) - - it('should simulate onBatchDelete callback behavior', () => { - const mockOnDelete = vi.fn() - - // Simulate the callback: () => segmentListDataHook.onDelete('') - const onBatchDelete = () => mockOnDelete('') - onBatchDelete() - - expect(mockOnDelete).toHaveBeenCalledWith('') - }) -}) - // Additional Coverage Tests for Inline Callbacks (lines 56-66, 78-83, 254) describe('Inline callback and hook initialization coverage', () => { @@ -1073,3 +1026,21 @@ describe('Inline callback and hook initialization coverage', () => { expect(searchInput)!.toHaveValue('test query') }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.byDocumentId.segments.get': async () => ({ + ...mockSegmentListData, + }), + 'datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch': mockOnChangeSwitch, + 'datasets.byDatasetId.documents.byDocumentId.segments.delete': mockOnDelete, + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch': async () => ({ + result: 'success', + }), + }), + } +}) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index 8c0d24a8dd6a20..93f4dab5ed5388 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { toast } from '@/app/notifications' import { AppToastHost } from '@/app/notifications/host' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import NewChildSegmentModal from '../new-child-segment' vi.mock('@/next/navigation', () => ({ @@ -39,13 +40,17 @@ vi.mock('../index', () => ({ // Mock useAddChildSegment const mockAddChildSegment = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.post': () => ({ - mutateAsync: mockAddChildSegment, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.post': ( + input, + ) => mockAddChildSegment(input), }), - }) + } }) vi.mock('../common/action-buttons', () => ({ @@ -225,11 +230,7 @@ describe('NewChildSegmentModal', () => { // Successful save describe('Successful Save', () => { it('should call addChildSegment when valid content is provided', async () => { - mockAddChildSegment.mockImplementation((_params, options) => { - options.onSuccess({ data: { id: 'new-child-id' } }) - options.onSettled() - return Promise.resolve() - }) + mockAddChildSegment.mockResolvedValue({ data: { id: 'new-child-id' } }) render() fireEvent.change(screen.getByTestId('content-input'), { @@ -250,17 +251,12 @@ describe('NewChildSegmentModal', () => { content: 'Valid content', }), }), - expect.any(Object), ) }) }) it('should show success notification after save', async () => { - mockAddChildSegment.mockImplementation((_params, options) => { - options.onSuccess({ data: { id: 'new-child-id' } }) - options.onSettled() - return Promise.resolve() - }) + mockAddChildSegment.mockResolvedValue({ data: { id: 'new-child-id' } }) render() fireEvent.change(screen.getByTestId('content-input'), { @@ -331,11 +327,7 @@ describe('NewChildSegmentModal', () => { describe('Add Another Behavior', () => { it('should close modal when add another is unchecked after save', async () => { const mockOnCancel = vi.fn() - mockAddChildSegment.mockImplementation((_params, options) => { - options.onSuccess({ data: { id: 'new-child-id' } }) - options.onSettled() - return Promise.resolve() - }) + mockAddChildSegment.mockResolvedValue({ data: { id: 'new-child-id' } }) render() @@ -356,11 +348,7 @@ describe('NewChildSegmentModal', () => { it('should not close modal when add another is checked after save', async () => { const mockOnCancel = vi.fn() - mockAddChildSegment.mockImplementation((_params, options) => { - options.onSuccess({ data: { id: 'new-child-id' } }) - options.onSettled() - return Promise.resolve() - }) + mockAddChildSegment.mockResolvedValue({ data: { id: 'new-child-id' } }) render() @@ -383,11 +371,7 @@ describe('NewChildSegmentModal', () => { it('should call viewNewlyAddedChildChunk when the toast action is clicked', async () => { mockParentMode = 'full-doc' const mockViewNewlyAddedChildChunk = vi.fn() - mockAddChildSegment.mockImplementation((_params, options) => { - options.onSuccess({ data: { id: 'new-child-id' } }) - options.onSettled() - return Promise.resolve() - }) + mockAddChildSegment.mockResolvedValue({ data: { id: 'new-child-id' } }) render( <> @@ -416,11 +400,7 @@ describe('NewChildSegmentModal', () => { it('should call onSave immediately in full-doc mode after save succeeds', async () => { mockParentMode = 'full-doc' const mockOnSave = vi.fn() - mockAddChildSegment.mockImplementation((_params, options) => { - options.onSuccess({ data: { id: 'new-child-id' } }) - options.onSettled() - return Promise.resolve() - }) + mockAddChildSegment.mockResolvedValue({ data: { id: 'new-child-id' } }) render() @@ -438,11 +418,7 @@ describe('NewChildSegmentModal', () => { it('should call onSave with the new child chunk in paragraph mode', async () => { mockParentMode = 'paragraph' const mockOnSave = vi.fn() - mockAddChildSegment.mockImplementation((_params, options) => { - options.onSuccess({ data: { id: 'new-child-id' } }) - options.onSettled() - return Promise.resolve() - }) + mockAddChildSegment.mockResolvedValue({ data: { id: 'new-child-id' } }) render() diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts index a5604deeb89b6a..1478402227fd7e 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts @@ -5,30 +5,21 @@ import type { } from '@dify/contracts/api/console/datasets/types.gen' import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' import type { ChunkingMode, ParentMode } from '@/models/datasets' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, renderHook } from '@testing-library/react' -import * as React from 'react' +import { act, waitFor } from '@testing-library/react' import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { useChildSegmentData } from '../use-child-segment-data' -// Type for mutation callbacks -type MutationResponse = { data: ChildChunkResponse } -type MutationCallbacks = { - onSuccess: (res: MutationResponse) => void - onSettled: () => void -} -type _ErrorCallback = { onSuccess?: () => void; onError: () => void } - // Hoisted Mocks const { + getChildren, mockParentMode, mockDatasetId, mockDocumentId, mockNotify, mockEventEmitter, - mockQueryClient, - mockChildSegmentListData, mockDeleteChildSegment, mockUpdateChildSegment, } = vi.hoisted(() => ({ @@ -37,34 +28,11 @@ const { mockDocumentId: { current: 'test-document-id' }, mockNotify: vi.fn(), mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, - mockQueryClient: { setQueryData: vi.fn(), invalidateQueries: vi.fn() }, - mockChildSegmentListData: { - current: { data: [] as ChildChunkResponse[], total: 0, total_pages: 0 } as - | ChildChunkListResponse - | undefined, - }, + getChildren: vi.fn(), mockDeleteChildSegment: vi.fn(), mockUpdateChildSegment: vi.fn(), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return { - ...mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get': - (options: { enabled?: boolean }) => ({ - isLoading: false, - data: options.enabled === false ? undefined : mockChildSegmentListData.current, - }), - 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.delete': - () => ({ mutateAsync: mockDeleteChildSegment }), - 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.patch': - () => ({ mutateAsync: mockUpdateChildSegment }), - }), - useQueryClient: () => mockQueryClient, - } -}) - vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { @@ -88,32 +56,24 @@ vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }), })) -const childChunkListsKey = - consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.key( - { - input: { params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' } }, - }, - ) - -const expectChildChunkListsRefetched = () => - expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: childChunkListsKey }) - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }) - -const createWrapper = () => { - const queryClient = createQueryClient() - return ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children) -} +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get': + getChildren, + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.delete': + mockDeleteChildSegment, + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.byChildChunkId.patch': + mockUpdateChildSegment, + }), + } +}) const createMockChildChunk = (overrides: Partial = {}): ChildChunkResponse => ({ - id: `child-${Math.random().toString(36).substr(2, 9)}`, + id: 'child-1', position: 1, segment_id: 'segment-1', content: 'Child chunk content', @@ -167,640 +127,235 @@ const defaultOptions = { updateSegmentInCache: vi.fn(), } -// Tests +const childPage = (data: ChildChunkResponse[] = []): ChildChunkListResponse => ({ + data, + total: data.length, + total_pages: data.length ? 1 : 0, + page: 1, + limit: 10, +}) +const parentKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.queryKey({ + input: { + params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' }, + query: { page: 1, limit: 10, keyword: '', enabled: 'all' }, + }, +}) +const renderChildren = ( + overrides: Partial[0]> = {}, + seed = true, +) => { + const queryClient = createConsoleQueryClient() + const children = [ + createMockChildChunk(), + createMockChildChunk({ id: 'child-2', content: 'Other content' }), + ] + const parent = createMockSegment({ child_chunks: children }) + queryClient.setQueryData(parentKey, { + data: [parent], + total: 1, + total_pages: 1, + page: 1, + limit: 10, + }) + const options = { + ...defaultOptions, + updateSegmentInCache: (id: string, update: (segment: SegmentResponse) => SegmentResponse) => { + queryClient.setQueryData( + parentKey, + (old) => + old && { + ...old, + data: old.data.map((segment) => (segment.id === id ? update(segment) : segment)), + }, + ) + }, + ...overrides, + } + const childKey = + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.childChunks.get.queryKey( + { + input: { + params: { + dataset_id: 'test-dataset-id', + document_id: 'test-document-id', + segment_id: options.segments[0]?.id ?? '', + }, + query: { + page: options.currentPage || 1, + limit: options.limit, + keyword: options.searchValue, + }, + }, + }, + ) + if (seed) queryClient.setQueryData(childKey, childPage(children)) + return { + ...renderHookWithConsoleQuery(() => useChildSegmentData(options), { queryClient }), + childKey, + } +} describe('useChildSegmentData', () => { beforeEach(() => { vi.clearAllMocks() mockParentMode.current = 'paragraph' - mockDatasetId.current = 'test-dataset-id' - mockDocumentId.current = 'test-document-id' - mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } - }) - - describe('Initial State', () => { - it('should return empty child segments initially', () => { - const { result } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - expect(result.current.childSegments).toEqual([]) - expect(result.current.isLoadingChildSegmentList).toBe(false) + getChildren.mockReset().mockImplementation(() => new Promise(() => {})) + mockDeleteChildSegment.mockReset().mockResolvedValue({ result: 'success' }) + mockUpdateChildSegment.mockReset().mockResolvedValue({ + data: createMockChildChunk({ + content: 'Updated content', + word_count: 50, + type: 'customized', + }), }) }) - describe('resetChildList', () => { - it('should call invalidChildSegmentList', () => { - const { result } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.resetChildList() - }) - - expectChildChunkListsRefetched() + it('loads child chunks and uses page one for the initial zero page', async () => { + getChildren.mockResolvedValue(childPage([createMockChildChunk()])) + const { result } = renderChildren({ currentPage: 0 }, false) + expect(result.current.isLoadingChildSegmentList).toBe(true) + await waitFor(() => expect(result.current.childSegments).toHaveLength(1)) + expect(result.current.isLoadingChildSegmentList).toBe(false) + expect(getChildren).toHaveBeenCalledWith({ + params: { + dataset_id: 'test-dataset-id', + document_id: 'test-document-id', + segment_id: 'segment-1', + }, + query: { page: 1, limit: 10, keyword: '' }, }) }) - describe('onDeleteChildChunk', () => { - it('should delete child chunk and update parent cache in paragraph mode', async () => { - mockParentMode.current = 'paragraph' - const updateSegmentInCache = vi.fn() - - mockDeleteChildSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - }), - { - wrapper: createWrapper(), - }, - ) - - await act(async () => { - await result.current.onDeleteChildChunk('seg-1', 'child-1') - }) - - expect(mockDeleteChildSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'common.actionMsg.modifiedSuccessfully', - }) - expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function)) - }) - - it('should delete child chunk and reset list in full-doc mode', async () => { - mockParentMode.current = 'full-doc' - - mockDeleteChildSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) - - const { result } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.onDeleteChildChunk('seg-1', 'child-1') - }) + it.each([{ isFullDocMode: false }, { segments: [] }])( + 'does not fetch children when the query is disabled (%j)', + async (options) => { + const { result } = renderChildren(options, false) + await act(async () => {}) + expect(getChildren).not.toHaveBeenCalled() + expect(result.current.childSegments).toEqual([]) + expect(result.current.isLoadingChildSegmentList).toBe(false) + }, + ) - expectChildChunkListsRefetched() + it('deletes the selected child from its parent cache without changing siblings', async () => { + const { result, queryClient } = renderChildren() + await act(async () => result.current.onDeleteChildChunk('segment-1', 'child-1')) + expect( + queryClient.getQueryData(parentKey)?.data[0]?.child_chunks?.map((child) => child.id), + ).toEqual(['child-2']) + expect(mockDeleteChildSegment).toHaveBeenCalledWith({ + params: { + dataset_id: 'test-dataset-id', + document_id: 'test-document-id', + segment_id: 'segment-1', + child_chunk_id: 'child-1', + }, }) - - it('should notify error on failure', async () => { - mockDeleteChildSegment.mockImplementation( - async (_params, { onError }: { onError: () => void }) => { - onError() - }, - ) - - const { result } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.onDeleteChildChunk('seg-1', 'child-1') - }) - - expect(mockNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'common.actionMsg.modifiedUnsuccessfully', - }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', }) }) - describe('handleUpdateChildChunk', () => { - it('should validate empty content', async () => { - const { result } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.handleUpdateChildChunk('seg-1', 'child-1', ' ') - }) - - expect(mockNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'datasetDocuments.segment.contentEmpty', - }) - expect(mockUpdateChildSegment).not.toHaveBeenCalled() - }) - - it('should update child chunk and parent cache in paragraph mode', async () => { - mockParentMode.current = 'paragraph' - const updateSegmentInCache = vi.fn() - const onCloseChildSegmentDetail = vi.fn() - const refreshChunkListDataWithDetailChanged = vi.fn() - - mockUpdateChildSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - content: 'updated content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, - }), - }) - onSettled() - }, - ) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - onCloseChildSegmentDetail, - refreshChunkListDataWithDetailChanged, - }), - { - wrapper: createWrapper(), - }, - ) - - await act(async () => { - await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content') - }) - - expect(mockUpdateChildSegment).toHaveBeenCalled() - expect(onCloseChildSegmentDetail).toHaveBeenCalled() - expect(updateSegmentInCache).toHaveBeenCalled() - expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled() - expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment') - expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment-done') - }) - - it('should update child chunk cache in full-doc mode', async () => { - mockParentMode.current = 'full-doc' - const onCloseChildSegmentDetail = vi.fn() - - mockUpdateChildSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - content: 'updated content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, - }), - }) - onSettled() - }, - ) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - onCloseChildSegmentDetail, - }), - { - wrapper: createWrapper(), - }, - ) - - await act(async () => { - await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content') - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() - }) + it('refetches the full-document child list after deletion', async () => { + mockParentMode.current = 'full-doc' + getChildren.mockResolvedValue(childPage([createMockChildChunk({ id: 'child-2' })])) + const { result } = renderChildren() + await act(async () => result.current.onDeleteChildChunk('segment-1', 'child-1')) + await waitFor(() => + expect(result.current.childSegments.map((child) => child.id)).toEqual(['child-2']), + ) + expect(getChildren).toHaveBeenCalledOnce() }) - describe('onSaveNewChildChunk', () => { - it('should update parent cache in paragraph mode', () => { - mockParentMode.current = 'paragraph' - const updateSegmentInCache = vi.fn() - const refreshChunkListDataWithDetailChanged = vi.fn() - const newChildChunk = createMockChildChunk({ id: 'new-child' }) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - refreshChunkListDataWithDetailChanged, - }), - { - wrapper: createWrapper(), - }, + it('reports a failed deletion without modifying cached children', async () => { + mockDeleteChildSegment.mockRejectedValue(new Error('Delete failed')) + const { result, queryClient } = renderChildren() + const original = queryClient.getQueryData(parentKey) + await act(async () => { + await expect(result.current.onDeleteChildChunk('segment-1', 'child-1')).rejects.toThrow( + 'Delete failed', ) - - act(() => { - result.current.onSaveNewChildChunk(newChildChunk) - }) - - expect(updateSegmentInCache).toHaveBeenCalled() - expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled() }) - - it('should reset child list in full-doc mode', () => { - mockParentMode.current = 'full-doc' - - const { result } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.onSaveNewChildChunk(createMockChildChunk()) - }) - - expectChildChunkListsRefetched() + expect(queryClient.getQueryData(parentKey)).toEqual(original) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.actionMsg.modifiedUnsuccessfully', }) }) - describe('viewNewlyAddedChildChunk', () => { - it('should set needScrollToBottom and not reset when adding new page', () => { - mockChildSegmentListData.current = { data: [], total: 10, total_pages: 1, page: 1, limit: 20 } - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - limit: 10, - }), - { - wrapper: createWrapper(), - }, - ) - - act(() => { - result.current.viewNewlyAddedChildChunk() - }) - - expect(result.current.needScrollToBottom.current).toBe(true) - }) - - it('should call resetChildList when not adding new page', () => { - mockChildSegmentListData.current = { data: [], total: 5, total_pages: 1, page: 1, limit: 20 } - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - limit: 10, - }), - { - wrapper: createWrapper(), - }, - ) - + it.each(['paragraph', 'full-doc'] as const)( + 'updates the %s cache after the response and settles the edit event', + async (mode) => { + mockParentMode.current = mode + const request = createDeferred<{ data: ChildChunkResponse }>() + mockUpdateChildSegment.mockReturnValue(request.promise) + const { result, queryClient, childKey } = renderChildren() + let update: Promise act(() => { - result.current.viewNewlyAddedChildChunk() + update = result.current.handleUpdateChildChunk('segment-1', 'child-1', 'Updated content') }) - - expectChildChunkListsRefetched() - }) - }) - - describe('Query disabled states', () => { - it('should disable query when not in fullDocMode', () => { - mockChildSegmentListData.current = { - data: [createMockChildChunk()], - total: 1, - total_pages: 1, - page: 1, - limit: 20, - } - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - isFullDocMode: false, - }), - { - wrapper: createWrapper(), - }, - ) - - // Query should be disabled but hook should still work - expect(result.current.childSegments).toEqual([]) - }) - - it('should disable query when segments is empty', () => { - mockChildSegmentListData.current = { - data: [createMockChildChunk()], - total: 1, - total_pages: 1, - page: 1, - limit: 20, - } - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - segments: [], - }), - { - wrapper: createWrapper(), - }, - ) - - expect(result.current.childSegments).toEqual([]) - }) - }) - - describe('Cache update callbacks', () => { - it('should use updateSegmentInCache when deleting in paragraph mode', async () => { - mockParentMode.current = 'paragraph' - const updateSegmentInCache = vi.fn() - - mockDeleteChildSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - }), - { - wrapper: createWrapper(), - }, - ) - + expect(defaultOptions.onCloseChildSegmentDetail).not.toHaveBeenCalled() await act(async () => { - await result.current.onDeleteChildChunk('seg-1', 'child-1') - }) - - expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function)) - - // Verify the updater function filters correctly - const updaterFn = updateSegmentInCache.mock.calls[0]![1] - const testSegment = createMockSegment({ - child_chunks: [ - createMockChildChunk({ id: 'child-1' }), - createMockChildChunk({ id: 'child-2' }), - ], - }) - const updatedSegment = updaterFn(testSegment) - expect(updatedSegment.child_chunks).toHaveLength(1) - expect(updatedSegment.child_chunks[0].id).toBe('child-2') - }) - - it('should use updateSegmentInCache when updating in paragraph mode', async () => { - mockParentMode.current = 'paragraph' - const updateSegmentInCache = vi.fn() - const onCloseChildSegmentDetail = vi.fn() - const refreshChunkListDataWithDetailChanged = vi.fn() - - mockUpdateChildSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - id: 'child-1', - content: 'new content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, - }), - }) - onSettled() - }, - ) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - updateSegmentInCache, - onCloseChildSegmentDetail, - refreshChunkListDataWithDetailChanged, + request.resolve({ + data: createMockChildChunk({ + content: 'Updated content', + type: 'customized', + word_count: 50, }), - { - wrapper: createWrapper(), - }, - ) - - await act(async () => { - await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') + }) + await update }) + const children = + mode === 'paragraph' + ? queryClient.getQueryData(parentKey)?.data[0]?.child_chunks + : queryClient.getQueryData(childKey)?.data + expect(children?.map((child) => child.content)).toEqual(['Updated content', 'Other content']) + expect(defaultOptions.onCloseChildSegmentDetail).toHaveBeenCalledOnce() + expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment') + expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment-done') + }, + ) - expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function)) - - // Verify the updater function maps correctly - const updaterFn = updateSegmentInCache.mock.calls[0]![1] - const testSegment = createMockSegment({ - child_chunks: [ - createMockChildChunk({ id: 'child-1', content: 'old content' }), - createMockChildChunk({ id: 'child-2', content: 'other content' }), - ], - }) - const updatedSegment = updaterFn(testSegment) - expect(updatedSegment.child_chunks).toHaveLength(2) - expect(updatedSegment.child_chunks[0].content).toBe('new content') - expect(updatedSegment.child_chunks[1].content).toBe('other content') + it('settles the editing event after a rejected update without closing or changing data', async () => { + mockUpdateChildSegment.mockRejectedValue(new Error('Update failed')) + const { result, queryClient } = renderChildren() + const original = queryClient.getQueryData(parentKey) + await act(async () => { + await expect( + result.current.handleUpdateChildChunk('segment-1', 'child-1', 'Updated content'), + ).rejects.toThrow('Update failed') }) + expect(queryClient.getQueryData(parentKey)).toEqual(original) + expect(defaultOptions.onCloseChildSegmentDetail).not.toHaveBeenCalled() + expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment-done') }) - describe('updateChildSegmentInCache in full-doc mode', () => { - it('should use updateChildSegmentInCache when updating in full-doc mode', async () => { - mockParentMode.current = 'full-doc' - const onCloseChildSegmentDetail = vi.fn() - - mockUpdateChildSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - id: 'child-1', - content: 'new content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, - }), - }) - onSettled() - }, - ) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - onCloseChildSegmentDetail, - }), - { - wrapper: createWrapper(), - }, - ) - - await act(async () => { - await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() - }) - - it('should handle updateChildSegmentInCache when old data is undefined', async () => { - mockParentMode.current = 'full-doc' - const onCloseChildSegmentDetail = vi.fn() - - // Capture the setQueryData callback to verify null-safety - mockQueryClient.setQueryData.mockImplementation( - (_key: unknown, updater: (old: unknown) => unknown) => { - if (typeof updater === 'function') { - // Invoke with undefined to cover the !old branch - const resultWithUndefined = updater(undefined) - expect(resultWithUndefined).toBeUndefined() - // Also test with real data - const resultWithData = updater({ - data: [ - createMockChildChunk({ id: 'child-1', content: 'old content' }), - createMockChildChunk({ id: 'child-2', content: 'other' }), - ], - total: 2, - total_pages: 1, - }) as ChildChunkListResponse - expect(resultWithData.data[0]!.content).toBe('new content') - expect(resultWithData.data[1]!.content).toBe('other') - } - }, - ) - - mockUpdateChildSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: MutationCallbacks) => { - onSuccess({ - data: createMockChildChunk({ - id: 'child-1', - content: 'new content', - type: 'customized', - word_count: 50, - updated_at: 1700000001, - }), - }) - onSettled() - }, - ) - - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - onCloseChildSegmentDetail, - }), - { - wrapper: createWrapper(), - }, - ) - - await act(async () => { - await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() - }) + it('does not create cache data when updating an uncached full document', async () => { + mockParentMode.current = 'full-doc' + const { result, queryClient, childKey } = renderChildren({}, false) + await act(async () => + result.current.handleUpdateChildChunk('segment-1', 'child-1', 'Updated content'), + ) + expect(queryClient.getQueryData(childKey)).toBeUndefined() }) - describe('Scroll to bottom effect', () => { - it('should scroll to bottom when childSegments change and needScrollToBottom is true', () => { - // Start with empty data - mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } - - const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - // Set up the ref to a mock DOM element - const mockScrollTo = vi.fn() - Object.defineProperty(result.current.childSegmentListRef, 'current', { - value: { scrollTo: mockScrollTo, scrollHeight: 500 }, - writable: true, - }) - result.current.needScrollToBottom.current = true - - // Change mock data to trigger the useEffect - mockChildSegmentListData.current = { - data: [createMockChildChunk({ id: 'new-child' })], - total: 1, - total_pages: 1, - page: 1, - limit: 20, - } - rerender() - - expect(mockScrollTo).toHaveBeenCalledWith({ top: 500, behavior: 'smooth' }) - expect(result.current.needScrollToBottom.current).toBe(false) - }) - - it('should not scroll when needScrollToBottom is false', () => { - mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } - - const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - const mockScrollTo = vi.fn() - Object.defineProperty(result.current.childSegmentListRef, 'current', { - value: { scrollTo: mockScrollTo, scrollHeight: 500 }, - writable: true, - }) - // needScrollToBottom remains false - - mockChildSegmentListData.current = { - data: [createMockChildChunk()], - total: 1, - total_pages: 1, - page: 1, - limit: 20, - } - rerender() - - expect(mockScrollTo).not.toHaveBeenCalled() - }) - - it('should not scroll when childSegmentListRef is null', () => { - mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } - - const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { - wrapper: createWrapper(), - }) - - // ref.current stays null, needScrollToBottom is true - result.current.needScrollToBottom.current = true - - mockChildSegmentListData.current = { - data: [createMockChildChunk()], - total: 1, - total_pages: 1, - page: 1, - limit: 20, - } - rerender() - - // needScrollToBottom stays true since scroll didn't happen - expect(result.current.needScrollToBottom.current).toBe(true) + it('rejects blank content before sending an update', async () => { + const { result } = renderChildren() + await act(async () => result.current.handleUpdateChildChunk('segment-1', 'child-1', ' ')) + expect(mockUpdateChildSegment).not.toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'datasetDocuments.segment.contentEmpty', }) }) - describe('Query params edge cases', () => { - it('should handle currentPage of 0 by defaulting to page 1', () => { - const { result } = renderHook( - () => - useChildSegmentData({ - ...defaultOptions, - currentPage: 0, - }), - { - wrapper: createWrapper(), - }, - ) - - // Should still work with page defaulted to 1 - expect(result.current.childSegments).toEqual([]) - }) + it('adds a new child to its parent and refreshes the owning detail', () => { + const { result, queryClient } = renderChildren() + act(() => result.current.onSaveNewChildChunk(createMockChildChunk({ id: 'new-child' }))) + expect( + queryClient.getQueryData(parentKey)?.data[0]?.child_chunks?.map((child) => child.id), + ).toEqual(['child-1', 'child-2', 'new-child']) + expect(defaultOptions.refreshChunkListDataWithDetailChanged).toHaveBeenCalledOnce() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index 6703473abed180..246cf0f38627fd 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -6,21 +6,13 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' import type { ChunkingMode, ParentMode } from '@/models/datasets' import type { SegmentImportStatus } from '@/types/dataset' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, renderHook } from '@testing-library/react' -import * as React from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data' import { segmentImportStatus } from '@/types/dataset' import { useSegmentListData } from '../use-segment-list-data' -// Type for mutation callbacks -type SegmentMutationResponse = { data: SegmentResponse } -type SegmentMutationCallbacks = { - onSuccess: (res: SegmentMutationResponse) => void - onSettled: () => void -} - // Mock file entity factory const createMockFileEntity = (overrides: Partial = {}): FileEntity => ({ id: 'file-1', @@ -43,7 +35,7 @@ const { mockDocumentId, mockNotify, mockEventEmitter, - mockQueryClient, + getSegments, mockSegmentListData, mockEnableSegment, mockDisableSegment, @@ -57,7 +49,7 @@ const { mockDocumentId: { current: 'test-document-id' }, mockNotify: vi.fn(), mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, - mockQueryClient: { setQueryData: vi.fn(), invalidateQueries: vi.fn() }, + getSegments: vi.fn(), mockSegmentListData: { current: { data: [] as SegmentResponse[], @@ -78,31 +70,6 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname.current, })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return { - ...mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.byDocumentId.segments.get': () => ({ - isLoading: false, - data: mockSegmentListData.current, - }), - 'datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch': () => ({ - mutateAsync: (input: { params: { action: string } }, callbacks: unknown) => - input.params.action === 'enable' - ? mockEnableSegment(input, callbacks) - : mockDisableSegment(input, callbacks), - }), - 'datasets.byDatasetId.documents.byDocumentId.segments.delete': () => ({ - mutateAsync: mockDeleteSegment, - }), - 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch': () => ({ - mutateAsync: mockUpdateSegment, - }), - }), - useQueryClient: () => mockQueryClient, - } -}) - vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { @@ -130,6 +97,9 @@ const segmentListsKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId input: { params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' } }, }) +let queryClient = createConsoleQueryClient() +let mockQueryClient = { invalidateQueries: vi.spyOn(queryClient, 'invalidateQueries') } + const expectSegmentListsRefetched = () => expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: segmentListsKey }) @@ -139,22 +109,35 @@ const expectOtherStatusListsMarkedStale = () => refetchType: 'none', }) -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }) - const createWrapper = () => { - const queryClient = createQueryClient() - return ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children) + queryClient.setQueryDefaults(segmentListsKey, { initialData: mockSegmentListData.current }) + return createConsoleQueryWrapper({ queryClient }).wrapper } +const currentKey = consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.queryKey({ + input: { + params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' }, + query: { page: 1, limit: 10, keyword: '', enabled: 'all' }, + }, +}) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.byDocumentId.segments.get': getSegments, + 'datasets.byDatasetId.documents.byDocumentId.segment.byAction.patch': (input: { + params: { action: string } + }) => + input.params.action === 'enable' ? mockEnableSegment(input) : mockDisableSegment(input), + 'datasets.byDatasetId.documents.byDocumentId.segments.delete': mockDeleteSegment, + 'datasets.byDatasetId.documents.byDocumentId.segments.bySegmentId.patch': mockUpdateSegment, + }), + } +}) const createMockSegment = (overrides: Partial = {}): SegmentResponse => ({ - id: `segment-${Math.random().toString(36).substr(2, 9)}`, + id: 'seg-1', position: 1, document_id: 'doc-1', content: 'Test content', @@ -200,6 +183,13 @@ const defaultOptions = { describe('useSegmentListData', () => { beforeEach(() => { vi.clearAllMocks() + queryClient = createConsoleQueryClient() + mockQueryClient = { invalidateQueries: vi.spyOn(queryClient, 'invalidateQueries') } + getSegments.mockReset().mockImplementation(() => new Promise(() => {})) + mockEnableSegment.mockReset().mockResolvedValue({ result: 'success' }) + mockDisableSegment.mockReset().mockResolvedValue({ result: 'success' }) + mockDeleteSegment.mockReset().mockResolvedValue({ result: 'success' }) + mockUpdateSegment.mockReset().mockResolvedValue({ data: createMockSegment() }) mockDocForm.current = ChunkingModeEnum.text as ChunkingMode mockParentMode.current = 'paragraph' mockDatasetId.current = 'test-dataset-id' @@ -366,11 +356,7 @@ describe('useSegmentListData', () => { describe('refreshChunkListWithStatusChanged', () => { it('should mark the other status lists stale when status is all', async () => { - mockEnableSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) + mockEnableSegment.mockResolvedValue({ result: 'success' }) const { result } = renderHook( () => @@ -395,11 +381,7 @@ describe('useSegmentListData', () => { }) it('should invalidate segment list when status is not all', async () => { - mockEnableSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) + mockEnableSegment.mockResolvedValue({ result: 'success' }) const { result } = renderHook( () => @@ -423,11 +405,7 @@ describe('useSegmentListData', () => { describe('onChangeSwitch', () => { it('should call enableSegment when enable is true', async () => { - mockEnableSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) + mockEnableSegment.mockResolvedValue({ result: 'success' }) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -442,11 +420,7 @@ describe('useSegmentListData', () => { }) it('should call disableSegment when enable is false', async () => { - mockDisableSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) + mockDisableSegment.mockResolvedValue({ result: 'success' }) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -460,11 +434,7 @@ describe('useSegmentListData', () => { }) it('should use selectedSegmentIds when segId is empty', async () => { - mockEnableSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) + mockEnableSegment.mockResolvedValue({ result: 'success' }) const { result } = renderHook( () => @@ -483,7 +453,6 @@ describe('useSegmentListData', () => { expect(mockEnableSegment).toHaveBeenCalledWith( expect.objectContaining({ query: { segment_id: ['seg-1', 'seg-2'] } }), - expect.any(Object), ) expect(mockNotify).toHaveBeenCalledWith({ type: 'success', @@ -492,18 +461,14 @@ describe('useSegmentListData', () => { }) it('should notify error on failure', async () => { - mockEnableSegment.mockImplementation( - async (_params, { onError }: { onError: () => void }) => { - onError() - }, - ) + mockEnableSegment.mockRejectedValue(new Error('Request failed')) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), }) await act(async () => { - await result.current.onChangeSwitch(true, 'seg-1') + await expect(result.current.onChangeSwitch(true, 'seg-1')).rejects.toThrow('Request failed') }) expect(mockNotify).toHaveBeenCalledWith({ @@ -516,11 +481,7 @@ describe('useSegmentListData', () => { describe('onDelete', () => { it('should call deleteSegment and resetList on success', async () => { const clearSelection = vi.fn() - mockDeleteSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) + mockDeleteSegment.mockResolvedValue({ result: 'success' }) const { result } = renderHook( () => @@ -546,11 +507,7 @@ describe('useSegmentListData', () => { it('should clear selection when deleting batch (no segId)', async () => { const clearSelection = vi.fn() - mockDeleteSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) + mockDeleteSegment.mockResolvedValue({ result: 'success' }) const { result } = renderHook( () => @@ -573,18 +530,14 @@ describe('useSegmentListData', () => { }) it('should notify error on failure', async () => { - mockDeleteSegment.mockImplementation( - async (_params, { onError }: { onError: () => void }) => { - onError() - }, - ) + mockDeleteSegment.mockRejectedValue(new Error('Request failed')) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), }) await act(async () => { - await result.current.onDelete('seg-1') + await expect(result.current.onDelete('seg-1')).rejects.toThrow('Request failed') }) expect(mockNotify).toHaveBeenCalledWith({ @@ -667,12 +620,7 @@ describe('useSegmentListData', () => { }) it('should call updateSegment with correct params', async () => { - mockUpdateSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }, - ) + mockUpdateSegment.mockResolvedValue({ data: createMockSegment() }) const onCloseSegmentDetail = vi.fn() const { result } = renderHook( @@ -702,12 +650,7 @@ describe('useSegmentListData', () => { }) it('should not close modal when needRegenerate is true', async () => { - mockUpdateSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }, - ) + mockUpdateSegment.mockResolvedValue({ data: createMockSegment() }) const onCloseSegmentDetail = vi.fn() const { result } = renderHook( @@ -729,12 +672,7 @@ describe('useSegmentListData', () => { }) it('should include attachments in params', async () => { - mockUpdateSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }, - ) + mockUpdateSegment.mockResolvedValue({ data: createMockSegment() }) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -754,7 +692,6 @@ describe('useSegmentListData', () => { expect.objectContaining({ body: expect.objectContaining({ attachment_ids: ['uploaded-1'] }), }), - expect.any(Object), ) }) }) @@ -818,20 +755,6 @@ describe('useSegmentListData', () => { }) }) - describe('updateSegmentInCache', () => { - it('should call queryClient.setQueryData', () => { - const { result } = renderHook(() => useSegmentListData(defaultOptions), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.updateSegmentInCache('seg-1', (seg) => ({ ...seg, enabled: false })) - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() - }) - }) - describe('Effect: pathname change', () => { it('should reset list when pathname changes', async () => { const clearSelection = vi.fn() @@ -875,12 +798,7 @@ describe('useSegmentListData', () => { describe('refreshChunkListDataWithDetailChanged', () => { it('should call correct invalidation for status all', async () => { - mockUpdateSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }, - ) + mockUpdateSegment.mockResolvedValue({ data: createMockSegment() }) const { result } = renderHook( () => @@ -901,12 +819,7 @@ describe('useSegmentListData', () => { }) it('should call correct invalidation for status true', async () => { - mockUpdateSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }, - ) + mockUpdateSegment.mockResolvedValue({ data: createMockSegment() }) const { result } = renderHook( () => @@ -927,12 +840,7 @@ describe('useSegmentListData', () => { }) it('should call correct invalidation for status false', async () => { - mockUpdateSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }, - ) + mockUpdateSegment.mockResolvedValue({ data: createMockSegment() }) const { result } = renderHook( () => @@ -957,12 +865,7 @@ describe('useSegmentListData', () => { it('should set content and answer for QA mode', async () => { mockDocForm.current = ChunkingModeEnum.qa as ChunkingMode - mockUpdateSegment.mockImplementation( - async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => { - onSuccess({ data: createMockSegment() }) - onSettled() - }, - ) + mockUpdateSegment.mockResolvedValue({ data: createMockSegment() }) const { result } = renderHook(() => useSegmentListData(defaultOptions), { wrapper: createWrapper(), @@ -979,93 +882,13 @@ describe('useSegmentListData', () => { answer: 'answer', }), }), - expect.any(Object), ) }) }) - describe('updateSegmentsInCache', () => { - it('should handle undefined old data', () => { - mockQueryClient.setQueryData.mockImplementation((_key, updater) => { - const result = typeof updater === 'function' ? updater(undefined) : updater - return result - }) - - const { result } = renderHook(() => useSegmentListData(defaultOptions), { - wrapper: createWrapper(), - }) - - // Call updateSegmentInCache which should handle undefined gracefully - act(() => { - result.current.updateSegmentInCache('seg-1', (seg) => ({ ...seg, enabled: false })) - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() - }) - - it('should map segments correctly when old data exists', () => { - const mockOldData = { - data: [ - createMockSegment({ id: 'seg-1', enabled: true }), - createMockSegment({ id: 'seg-2', enabled: true }), - ], - total: 2, - total_pages: 1, - } - - mockQueryClient.setQueryData.mockImplementation((_key, updater) => { - const result = typeof updater === 'function' ? updater(mockOldData) : updater - // Verify the updater transforms the data correctly - expect(result.data[0].enabled).toBe(false) // seg-1 should be updated - expect(result.data[1].enabled).toBe(true) // seg-2 should remain unchanged - return result - }) - - const { result } = renderHook(() => useSegmentListData(defaultOptions), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.updateSegmentInCache('seg-1', (seg) => ({ ...seg, enabled: false })) - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() - }) - }) - - describe('updateSegmentsInCache batch', () => { - it('should handle undefined old data in batch update', async () => { - mockQueryClient.setQueryData.mockImplementation((_key, updater) => { - const result = typeof updater === 'function' ? updater(undefined) : updater - return result - }) - - mockEnableSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, - ) - - const { result } = renderHook( - () => - useSegmentListData({ - ...defaultOptions, - selectedSegmentIds: ['seg-1', 'seg-2'], - }), - { - wrapper: createWrapper(), - }, - ) - - await act(async () => { - await result.current.onChangeSwitch(true, '') - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() - }) - - it('should map multiple segments correctly when old data exists', async () => { - const mockOldData = { + describe('cache updates', () => { + it('updates only selected cached segments and marks other status views stale', async () => { + mockSegmentListData.current = { data: [ createMockSegment({ id: 'seg-1', enabled: false }), createMockSegment({ id: 'seg-2', enabled: false }), @@ -1073,41 +896,60 @@ describe('useSegmentListData', () => { ], total: 3, total_pages: 1, + page: 1, + limit: 10, } - - mockQueryClient.setQueryData.mockImplementation((_key, updater) => { - const result = typeof updater === 'function' ? updater(mockOldData) : updater - // Verify only selected segments are updated - if (result && result.data) { - expect(result.data[0].enabled).toBe(true) // seg-1 should be updated - expect(result.data[1].enabled).toBe(true) // seg-2 should be updated - expect(result.data[2].enabled).toBe(false) // seg-3 should remain unchanged - } - return result + const { result } = renderHook( + () => useSegmentListData({ ...defaultOptions, selectedSegmentIds: ['seg-1', 'seg-2'] }), + { wrapper: createWrapper() }, + ) + const otherKey = + consoleQuery.datasets.byDatasetId.documents.byDocumentId.segments.get.queryKey({ + input: { + params: { dataset_id: 'test-dataset-id', document_id: 'test-document-id' }, + query: { page: 1, limit: 10, keyword: '', enabled: 'false' }, + }, + }) + queryClient.setQueryData(otherKey, mockSegmentListData.current) + await act(async () => result.current.onChangeSwitch(true)) + expect(queryClient.getQueryData(currentKey)?.data.map((segment) => segment.enabled)).toEqual([ + true, + true, + false, + ]) + expect(queryClient.getQueryState(otherKey)?.isInvalidated).toBe(true) + await waitFor(() => expect(result.current.segments[0]?.enabled).toBe(true)) + }) + + it('applies a single updater to the real cache and leaves sibling segments intact', async () => { + mockSegmentListData.current = { + data: [createMockSegment({ id: 'seg-1' }), createMockSegment({ id: 'seg-2' })], + total: 2, + total_pages: 1, + page: 1, + limit: 10, + } + const { result } = renderHook(() => useSegmentListData(defaultOptions), { + wrapper: createWrapper(), }) - - mockEnableSegment.mockImplementation( - async (_params, { onSuccess }: { onSuccess: () => void }) => { - onSuccess() - }, + act(() => + result.current.updateSegmentInCache('seg-1', (segment) => ({ ...segment, enabled: false })), ) + expect(queryClient.getQueryData(currentKey)?.data.map((segment) => segment.enabled)).toEqual([ + false, + true, + ]) + await waitFor(() => expect(result.current.segments[0]?.enabled).toBe(false)) + }) + it('does not synthesize list data when a mutation completes before the first response', async () => { + mockSegmentListData.current = undefined const { result } = renderHook( - () => - useSegmentListData({ - ...defaultOptions, - selectedSegmentIds: ['seg-1', 'seg-2'], - }), - { - wrapper: createWrapper(), - }, + () => useSegmentListData({ ...defaultOptions, selectedSegmentIds: ['seg-1'] }), + { wrapper: createWrapper() }, ) - - await act(async () => { - await result.current.onChangeSwitch(true, '') - }) - - expect(mockQueryClient.setQueryData).toHaveBeenCalled() + await act(async () => result.current.onChangeSwitch(true)) + expect(queryClient.getQueryData(currentKey)).toBeUndefined() }) }) }) diff --git a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx index ba4c829955f7df..cd60ae9f3b8ec9 100644 --- a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx @@ -1,13 +1,16 @@ import { QueryClient } from '@tanstack/react-query' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { IndexingType } from '@/app/components/datasets/create/step-two/hooks/use-indexing-config' +import DatasetDetailContext from '@/context/dataset-detail' import { consoleQuery } from '@/service/console' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { renderWithConsoleQuery as renderWithoutUrlState } from '@/test/console/query-data' +import { createDatasetFixture } from '@/test/fixtures/dataset' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import DocumentSettings from '../document-settings' const mockPush = vi.fn() const mockBack = vi.fn() -const mockSetSettingsDestination = vi.fn() const mockUseDocumentTitle = vi.hoisted(() => vi.fn()) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ @@ -16,18 +19,6 @@ vi.mock('@/next/navigation', () => ({ }), })) -// Mock use-context-selector -vi.mock('use-context-selector', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - useContext: () => ({ - indexingTechnique: 'qualified', - dataset: { id: 'dataset-1', name: 'Dataset 1' }, - }), - } -}) - vi.mock('@/hooks/use-document-title', () => ({ default: mockUseDocumentTitle, })) @@ -125,10 +116,28 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({ ), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) +const onUrlUpdate = vi.fn() +const renderWithUrlState = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) +const render = ( + ui: Parameters[0], + ...options: [Parameters[1]?] +) => { + const wrap = (content: Parameters[0]) => ( + + {content} + + ) + const rendered = renderWithUrlState(wrap(ui), ...options) + return { + ...rendered, + rerender: (nextUi: Parameters[0]) => rendered.rerender(wrap(nextUi)), + } +} describe('DocumentSettings', () => { beforeEach(() => { @@ -227,12 +236,14 @@ describe('DocumentSettings', () => { expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/document-1') }) - it('should open model provider integrations settings when setting button is clicked', () => { + it('should open model provider integrations settings when setting button is clicked', async () => { render() fireEvent.click(screen.getByTestId('setting-btn')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'), + ) }) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx index 61c1d944e38f8f..2074f1be8b5597 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ import type { PipelineExecutionLogResponse } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { DatasourceType } from '@/models/pipeline' import { consoleQuery } from '@/service/console' +import { createDeferred } from '@/test/deferred' import PipelineSettings from '../index' // Mock Next.js router @@ -26,14 +27,12 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock API hooks for PipelineSettings const mockUsePipelineExecutionLog = vi.fn() const mockMutateAsync = vi.fn() -const mockUseRunPublishedPipeline = vi.fn() vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { 'datasets.byDatasetId.documents.byDocumentId.pipelineExecutionLog.get': (o: { input: { params: { dataset_id: string; document_id: string } } }) => mockUsePipelineExecutionLog(o.input.params), - 'rag.pipelines.byPipelineId.workflows.published.run.post': () => mockUseRunPublishedPipeline(), // For ProcessDocuments component 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': () => ({ data: { variables: [] }, @@ -181,12 +180,7 @@ describe('PipelineSettings', () => { isError: false, }) - // Default: useRunPublishedPipeline mock - mockUseRunPublishedPipeline.mockReturnValue({ - mutateAsync: mockMutateAsync, - isIdle: true, - isPending: false, - }) + mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) }) // ==================== Rendering Tests ==================== @@ -390,16 +384,12 @@ describe('PipelineSettings', () => { original_document_id: 'document-456', }), }), - expect.any(Object), ) }) }) it('should navigate to documents list after successful process', async () => { - mockMutateAsync.mockImplementation((_request, options) => { - options?.onSuccess?.() - return Promise.resolve({}) - }) + mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() renderWithProviders() @@ -413,10 +403,7 @@ describe('PipelineSettings', () => { }) it('should invalidate document cache after successful process', async () => { - mockMutateAsync.mockImplementation((_request, options) => { - options?.onSuccess?.() - return Promise.resolve({}) - }) + mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() renderWithProviders() @@ -457,17 +444,13 @@ describe('PipelineSettings', () => { params: { pipeline_id: mockPipelineId }, body: expect.objectContaining({ is_preview: true }), }), - expect.any(Object), ) }) }) it('should update estimateData on successful preview', async () => { const mockOutputs = { total_nodes: 1, total_segments: 0, preview: [], qa_preview: [] } - mockMutateAsync.mockImplementation((_req, opts) => { - opts?.onSuccess?.({ data: { outputs: mockOutputs } }) - return Promise.resolve({ data: { outputs: mockOutputs } }) - }) + mockMutateAsync.mockResolvedValue({ data: { outputs: mockOutputs } }) const props = createDefaultProps() renderWithProviders() @@ -502,20 +485,17 @@ describe('PipelineSettings', () => { // Assert - inputs come from initialData which is transformed by useInitialData // Since usePublishedPipelineProcessingParams returns empty variables, inputs is {} await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith( - { - params: { pipeline_id: mockPipelineId }, - body: { - inputs: {}, - start_node_id: 'node-xyz', - datasource_type: DatasourceType.localFile, - datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }], - is_preview: true, - response_mode: 'blocking', - }, + expect(mockMutateAsync).toHaveBeenCalledWith({ + params: { pipeline_id: mockPipelineId }, + body: { + inputs: {}, + start_node_id: 'node-xyz', + datasource_type: DatasourceType.localFile, + datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }], + is_preview: true, + response_mode: 'blocking', }, - expect.any(Object), - ) + }) }) }) }) @@ -595,10 +575,7 @@ describe('PipelineSettings', () => { it('should update estimateData after successful preview', async () => { const mockEstimateData = { total_nodes: 1, total_segments: 0, preview: [], qa_preview: [] } - mockMutateAsync.mockImplementation((_req, opts) => { - opts?.onSuccess?.({ data: { outputs: mockEstimateData } }) - return Promise.resolve({ data: { outputs: mockEstimateData } }) - }) + mockMutateAsync.mockResolvedValue({ data: { outputs: mockEstimateData } }) const props = createDefaultProps() renderWithProviders() @@ -621,7 +598,6 @@ describe('PipelineSettings', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ is_preview: false }) }), - expect.any(Object), ) }) }) @@ -636,69 +612,41 @@ describe('PipelineSettings', () => { await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ is_preview: true }) }), - expect.any(Object), ) }) }) - it('should pass isPending=true to ChunkPreview when preview is pending', async () => { - // Arrange - Start with isPending=false so buttons are enabled - let isPendingState = false - mockUseRunPublishedPipeline.mockImplementation(() => ({ - mutateAsync: mockMutateAsync, - isIdle: !isPendingState, - isPending: isPendingState, - })) - - // A promise that never resolves to keep the pending state - const pendingPromise = new Promise(() => undefined) - // When mutateAsync is called, set isPending to true and trigger rerender - mockMutateAsync.mockImplementation(() => { - isPendingState = true - return pendingPromise - }) - - const props = createDefaultProps() - const { rerender } = renderWithProviders() - - // Act - Click preview button (sets isPreview.current = true and calls mutateAsync) + it('shows preview pending until the request resolves', async () => { + const response = createDeferred<{ data: { outputs: object } }>() + mockMutateAsync.mockReturnValue(response.promise) + renderWithProviders() fireEvent.click(screen.getByTestId('preview-btn')) - - // Update mock and rerender to reflect isPending=true state - mockUseRunPublishedPipeline.mockReturnValue({ - mutateAsync: mockMutateAsync, - isIdle: false, - isPending: true, - }) - rerender( - - - , - ) - - // Assert - isPending && isPreview.current should both be true now - expect(screen.getByTestId('is-pending')).toHaveTextContent('true') + await waitFor(() => expect(screen.getByTestId('is-pending')).toHaveTextContent('true')) + expect(screen.getByTestId('preview-btn')).toBeDisabled() + await act(async () => response.resolve({ data: { outputs: {} } })) + await waitFor(() => expect(screen.getByTestId('is-pending')).toHaveTextContent('false')) }) - it('should pass isPending=false to ChunkPreview when process is pending (not preview)', async () => { - // Arrange - isPending is true but isPreview.current is false - mockUseRunPublishedPipeline.mockReturnValue({ - mutateAsync: mockMutateAsync, - isIdle: false, - isPending: true, - }) - mockMutateAsync.mockReturnValue(new Promise(() => undefined)) - const props = createDefaultProps() - - renderWithProviders() + it('does not show preview pending when saving documents', async () => { + mockMutateAsync.mockReturnValue(new Promise(() => {})) + renderWithProviders() fireEvent.click( screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }), ) - - // Assert - isPending && isPreview.current should be false (true && false = false) - await waitFor(() => { - expect(screen.getByTestId('is-pending')).toHaveTextContent('false') - }) + await waitFor(() => expect(mockMutateAsync).toHaveBeenCalled()) + await waitFor(() => expect(screen.getByTestId('preview-btn')).toBeDisabled()) + expect(screen.getByTestId('is-pending')).toHaveTextContent('false') }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.workflows.published.run.post': (input) => mockMutateAsync(input), + }), + } +}) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts index abacd44aba673e..82c8e1d10d0755 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts @@ -1,80 +1,61 @@ -import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { act, waitFor } from '@testing-library/react' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { useInputVariables } from '../hooks' -let mockPipelineId: string | undefined - +const { getParams } = vi.hoisted(() => ({ getParams: vi.fn() })) vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: ( - selector: (state: { dataset: { pipeline_id?: string } | null }) => unknown, - ) => selector({ dataset: mockPipelineId ? { pipeline_id: mockPipelineId } : null }), + selector: (state: { dataset: { pipeline_id: string } }) => unknown, + ) => selector({ dataset: { pipeline_id: 'pipeline-1' } }), })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': getParams, + }), + } +}) -let mockParamsReturn: { - data: Record | undefined - isFetching: boolean +const response = { + variables: [ + { + variable: 'chunkSize', + label: 'Chunk size', + type: 'number', + required: true, + default_value: 100, + }, + ], } - -const mockUsePublishedPipelineProcessingParams = vi.fn( - (_params: { pipeline_id: string; node_id: string }) => mockParamsReturn, -) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.byPipelineId.workflows.published.processing.parameters.get': (o: { - input: { params: { pipeline_id: string }; query: { node_id: string } } - enabled?: boolean - }) => - mockUsePublishedPipelineProcessingParams({ - pipeline_id: o.input.params.pipeline_id, - node_id: o.input.query.node_id, - }), - }) -}) +const renderVariables = () => renderHook(() => useInputVariables('node-1')) describe('useInputVariables', () => { beforeEach(() => { vi.clearAllMocks() - mockPipelineId = 'pipeline-123' - mockParamsReturn = { - data: undefined, - isFetching: false, - } + getParams.mockResolvedValue(response) }) - - // Returns paramsConfig from API - describe('Data Retrieval', () => { - it('should return paramsConfig from API', () => { - const mockConfig = { variables: [{ name: 'var1', type: 'string' }] } - mockParamsReturn = { data: mockConfig, isFetching: false } - - const { result } = renderHook(() => useInputVariables('node-456')) - - expect(result.current.paramsConfig).toEqual(mockConfig) - }) - - it('should return isFetchingParams loading state', () => { - mockParamsReturn = { data: undefined, isFetching: true } - - const { result } = renderHook(() => useInputVariables('node-456')) - - expect(result.current.isFetchingParams).toBe(true) + it('fetches processing parameters for the current pipeline and node', async () => { + const { result } = renderVariables() + await waitFor(() => expect(result.current.paramsConfig?.variables).toHaveLength(1)) + expect(result.current.paramsConfig?.variables[0]).toMatchObject(response.variables[0]!) + expect(getParams).toHaveBeenCalledWith({ + params: { pipeline_id: 'pipeline-1' }, + query: { node_id: 'node-1' }, }) + expect(result.current.isFetchingParams).toBe(false) }) - - // Passes correct parameters to API hook - describe('Parameter Passing', () => { - it('should pass correct pipeline_id and node_id to API hook', () => { - mockPipelineId = 'pipeline-789' - mockParamsReturn = { data: undefined, isFetching: false } - - renderHook(() => useInputVariables('node-abc')) - - expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({ - pipeline_id: 'pipeline-789', - node_id: 'node-abc', - }) - }) + it('stays fetching until processing parameters resolve', async () => { + const pending = createDeferred() + getParams.mockReturnValue(pending.promise) + const { result } = renderVariables() + expect(result.current.isFetchingParams).toBe(true) + expect(result.current.paramsConfig).toBeUndefined() + await act(async () => pending.resolve(response)) + await waitFor(() => expect(result.current.isFetchingParams).toBe(false)) + expect(result.current.paramsConfig?.variables).toHaveLength(1) }) }) diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx index 7958e504dbcaf6..79f8d0adedf092 100644 --- a/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx +++ b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.tsx @@ -4,19 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { renderHookWithNuqs } from '@/test/nuqs-testing' import { useDocumentListQueryState } from '../use-document-list-query-state' -vi.mock('@/models/datasets', () => ({ - DisplayStatusList: [ - 'queuing', - 'indexing', - 'paused', - 'error', - 'available', - 'enabled', - 'disabled', - 'archived', - ], -})) - const renderWithAdapter = (searchParams = '') => { return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams }) } diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts index 9a5e9c5cb684c4..7e331a2a5f0ad2 100644 --- a/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts +++ b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts @@ -12,23 +12,6 @@ let mockQuery: DocumentListQuery = { sort: '-created_at', } -vi.mock('@/models/datasets', () => ({ - DisplayStatusList: [ - 'queuing', - 'indexing', - 'paused', - 'error', - 'available', - 'enabled', - 'disabled', - 'archived', - ], -})) - -vi.mock('ahooks', () => ({ - useDebounce: (value: unknown, _options?: { wait?: number }) => value, -})) - vi.mock('../use-document-list-query-state', async () => { const React = await import('react') return { diff --git a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index ef47e12b20859b..f74ae37c6aaa12 100644 --- a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -1,6 +1,7 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import StatusItem from '../index' const toastMocks = vi.hoisted(() => { @@ -29,13 +30,6 @@ const toastMocks = vi.hoisted(() => { }), } }) -vi.mock('use-context-selector', () => ({ - createContext: (defaultValue: unknown) => React.createContext(defaultValue), - useContext: () => ({ - notify: toastMocks.api, - }), - useContextSelector: (context: unknown, selector: (state: unknown) => unknown) => selector({}), -})) vi.mock('@/app/notifications', () => ({ toast: toastMocks.api, @@ -63,15 +57,18 @@ const statusMocks: Record unknown> = { enable: (input) => mockEnable(input), disable: (input) => mockDisable(input), } -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.status.byAction.batch.patch': () => ({ - mutateAsync: (input: { params: { action: string } }) => - statusMocks[input.params.action]!(input), +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.status.byAction.batch.patch': (input: { + params: { action: string } + }) => statusMocks[input.params.action]!(input), + 'datasets.byDatasetId.documents.delete': (input) => mockDelete(input), }), - 'datasets.byDatasetId.documents.delete': () => ({ mutateAsync: mockDelete }), - }) + } }) beforeEach(() => { diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx index d291e3d03119b6..825b3562d7f630 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx @@ -1,33 +1,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -vi.mock('@/app/components/base/param-item/top-k-item', () => ({ - default: ({ onChange }: { onChange: (key: string, value: number) => void }) => ( - - ), -})) - -vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ - default: ({ - onChange, - onSwitchChange, - }: { - onChange: (key: string, value: number) => void - onSwitchChange: (key: string, value: boolean) => void - }) => ( -
- - -
- ), -})) - const { default: RetrievalSettings } = await import('../RetrievalSettings') const defaultProps = { @@ -58,16 +31,23 @@ describe('RetrievalSettings', () => { const user = userEvent.setup() render() - await user.click(screen.getByRole('button', { name: 'Set top K to 8' })) + await user.clear(screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' })) + await user.type(screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }), '8') expect(defaultProps.onChange).toHaveBeenCalledWith({ top_k: 8 }) }) it('updates the score threshold', async () => { const user = userEvent.setup() - render() + render() - await user.click(screen.getByRole('button', { name: 'Set score threshold to 0.9' })) + await user.clear( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + ) + await user.type( + screen.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }), + '0.9', + ) expect(defaultProps.onChange).toHaveBeenCalledWith({ score_threshold: 0.9 }) }) @@ -76,7 +56,7 @@ describe('RetrievalSettings', () => { const user = userEvent.setup() render() - await user.click(screen.getByRole('button', { name: 'Enable score threshold' })) + await user.click(screen.getByRole('switch', { name: 'appDebug.datasetConfig.score_threshold' })) expect(defaultProps.onChange).toHaveBeenCalledWith({ score_threshold_enabled: true }) }) diff --git a/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx index ad086531c80cd6..fe20a7b52c7b93 100644 --- a/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx +++ b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx @@ -28,14 +28,17 @@ vi.mock('@/context/dataset-detail', () => ({ }), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.apiKeys.byStatus.post': () => ({ - mutateAsync: (input: { params: { status: string } }) => - input.params.status === 'enable' ? mockEnableApi(input) : mockDisableApi(input), +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.apiKeys.byStatus.post': (input) => + ((input: { params: { status: string } }) => + input.params.status === 'enable' ? mockEnableApi(input) : mockDisableApi(input))(input), }), - }) + } }) vi.mock('@/hooks/use-api-access-url', () => ({ diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx index 1367a27f8ba92e..5f4328539d441a 100644 --- a/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx @@ -1,44 +1,6 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' - -// Capture the onOpenChange callback to simulate hover interactions -let capturedOnOpenChange: ((open: boolean) => void) | null = null - -vi.mock('@floating-ui/react', () => ({ - autoUpdate: vi.fn(), - flip: vi.fn(), - shift: vi.fn(), - offset: vi.fn(), - FloatingFocusManager: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - useFloating: ({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) => { - capturedOnOpenChange = onOpenChange ?? null - return { - refs: { setReference: vi.fn(), setFloating: vi.fn() }, - floatingStyles: {}, - context: { - open: false, - onOpenChange: vi.fn(), - refs: { domReference: { current: null } }, - nodeId: undefined, - }, - } - }, - useHover: () => ({}), - useDismiss: () => ({}), - useRole: () => ({}), - useInteractions: () => ({ - getReferenceProps: () => ({}), - getFloatingProps: () => ({}), - }), -})) - -const { EditSlice } = await import('../edit-slice') - -// Helper to find divider span (zero-width space) -const findDividerSpan = (container: HTMLElement) => - Array.from(container.querySelectorAll('span')).find((s) => s.textContent?.includes('\u200B')) +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { EditSlice } from '../edit-slice' describe('EditSlice', () => { const defaultProps = { @@ -47,82 +9,56 @@ describe('EditSlice', () => { onDelete: vi.fn(), } - beforeEach(() => { - vi.clearAllMocks() - capturedOnOpenChange = null - }) - - // ---- Rendering Tests ---- - it('should render label and text', () => { - render() - expect(screen.getByText('S1')).toBeInTheDocument() - expect(screen.getByText('Sample text content')).toBeInTheDocument() - }) - - it('should render divider by default', () => { - const { container } = render() - expect(findDividerSpan(container)).toBeTruthy() - }) - - it('should not render divider when showDivider is false', () => { - const { container } = render() - expect(findDividerSpan(container)).toBeFalsy() - }) - - // ---- Class Name Tests ---- - - it('should pass rest props to wrapper', () => { - render() - expect(screen.getByTestId('edit-slice')).toBeInTheDocument() - }) - - // ---- Floating UI / Delete Button Tests ---- - it('should not show delete button when floating is closed', () => { + it('reveals the remove action on hover and hides it when leaving the slice', async () => { + const user = userEvent.setup() render() - expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument() - }) - - it('should show delete button when onOpenChange triggers open', () => { - render() - act(() => { - capturedOnOpenChange?.(true) - }) - expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.operation.remove' }), + ).not.toBeInTheDocument() + + await user.hover(screen.getByText('Sample text content')) + expect( + await screen.findByRole('button', { name: 'common.operation.remove' }), + ).toBeInTheDocument() + await user.unhover(screen.getByText('Sample text content')) + await waitFor(() => + expect( + screen.queryByRole('button', { name: 'common.operation.remove' }), + ).not.toBeInTheDocument(), + ) }) - it('should call onDelete when delete button is clicked', () => { + it('removes the slice once and closes the action without activating its parent', async () => { + const user = userEvent.setup() const onDelete = vi.fn() - render() - act(() => { - capturedOnOpenChange?.(true) - }) - fireEvent.click(screen.getByRole('button')) - expect(onDelete).toHaveBeenCalledTimes(1) - }) - - it('should close floating after delete button is clicked', () => { - render() - act(() => { - capturedOnOpenChange?.(true) - }) - expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button')) - expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument() - }) - - it('should stop event propagation on delete click', () => { const parentClick = vi.fn() render(
- +
, ) - act(() => { - capturedOnOpenChange?.(true) - }) - fireEvent.click(screen.getByRole('button')) + + await user.hover(screen.getByText('Sample text content')) + await user.click(await screen.findByRole('button', { name: 'common.operation.remove' })) + + expect(onDelete).toHaveBeenCalledOnce() expect(parentClick).not.toHaveBeenCalled() + await waitFor(() => + expect( + screen.queryByRole('button', { name: 'common.operation.remove' }), + ).not.toBeInTheDocument(), + ) }) - // ---- Destructive Hover Style Tests ---- + it('prevents deleting a disabled slice', async () => { + const user = userEvent.setup() + const onDelete = vi.fn() + render() + + await user.hover(screen.getByText('Sample text content')) + const remove = await screen.findByRole('button', { name: 'common.operation.remove' }) + expect(remove).toBeDisabled() + await user.click(remove) + expect(onDelete).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx index 956f75bc796155..7a3e40f98abf5f 100644 --- a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx @@ -1,19 +1,21 @@ import type { - DatasetDetailWithPartialMembersResponse, DatasetQueryDetailResponse, HitTestingRecord, HitTestingResponse, } from '@dify/contracts/api/console/datasets/types.gen' -import type { GetWorkspacesCurrentModelsModelTypesByModelTypeData } from '@dify/contracts/api/console/workspaces/types.gen' -import type { OperationKey } from '@orpc/tanstack-query' import type { ReactNode } from 'react' import type { RetrievalConfig } from '@/types/app' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { describe, expect, it, vi } from 'vite-plus/test' +import DatasetDetailContext from '@/context/dataset-detail' +import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { render } from '@/test/console/render' +import { createDeferred } from '@/test/deferred' +import { createDatasetFixture } from '@/test/fixtures/dataset' import { createHitTestingRecordFixture } from '@/test/fixtures/hit-testing' import { RETRIEVE_METHOD } from '@/types/app' import HitTestingPage from '../index' @@ -88,8 +90,7 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => new URLSearchParams(), })) -// Mock use-context-selector -const mockDataset = { +const mockDataset = createDatasetFixture({ id: 'dataset-1', name: 'Test Dataset', provider: 'vendor', @@ -109,29 +110,13 @@ const mockDataset = { }, is_multimodal: false, permission_keys: ['dataset.acl.retrieval_recall'], -} as Partial +}) let mockConsoleState = { userProfile: { id: 'user-1' }, workspacePermissionKeys: [] as string[], } -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(() => ({ dataset: mockDataset })), - useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), - createContext: vi.fn(() => ({})), -})) - -// Mock dataset detail context -vi.mock('@/context/dataset-detail', () => ({ - default: {}, - useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), - useDatasetDetailContextWithSelector: vi.fn( - (selector: (v: { dataset?: typeof mockDataset }) => unknown) => - selector({ dataset: mockDataset as DatasetDetailWithPartialMembersResponse }), - ), -})) - vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') @@ -148,17 +133,47 @@ vi.mock('@/features/system-features/state', async () => { return createSystemFeaturesStateModuleMock(() => mockConsoleState) }) -const mockRecordsRefetch = vi.fn() -const mockHitTestingMutateAsync = vi.fn() -const mockExternalHitTestingMutateAsync = vi.fn() - -const { mockRecordsQuery } = vi.hoisted(() => ({ mockRecordsQuery: vi.fn() })) - -// The page owns one mutation per retrieval kind, told apart by mutation key. -const { mockHitTestingState, mockExternalHitTestingState } = vi.hoisted(() => ({ - mockHitTestingState: vi.fn(), - mockExternalHitTestingState: vi.fn(), -})) +const { getRecords, mockHitTestingMutateAsync, mockExternalHitTestingMutateAsync } = vi.hoisted( + () => ({ + getRecords: vi.fn(), + mockHitTestingMutateAsync: vi.fn(), + mockExternalHitTestingMutateAsync: vi.fn(), + }), +) +let recordsData: + | { + data: DatasetQueryDetailResponse[] + total: number + page: number + limit: number + has_more: boolean + } + | undefined +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.queries.get': getRecords, + }), + consoleClient: { + datasets: { + byDatasetId: { + hitTesting: { post: mockHitTestingMutateAsync }, + externalHitTesting: { post: mockExternalHitTestingMutateAsync }, + }, + }, + }, + } +}) +// Model catalog is a fixed boundary for retrieval settings; queries and mutations under test stay real. +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.models.modelTypes.byModelType.get': () => ({ data: [], isPending: false }), + }) +}) // Mock breakpoints hook vi.mock('@/hooks/use-breakpoints', () => ({ @@ -179,16 +194,6 @@ vi.mock('@/hooks/use-timestamp', () => ({ })) // Mock use-common to avoid QueryClient issues in nested hooks -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - file_size_limit: 10, - batch_count_limit: 5, - image_file_size_limit: 5, - }, - isLoading: false, - })), -})) // Store ref to ImageUploader onChange for testing let _mockImageUploaderOnChange: @@ -288,6 +293,7 @@ const createConsoleQueryClient = () => queries: { retry: false, gcTime: 0, + staleTime: Infinity, }, mutations: { retry: false, @@ -296,9 +302,25 @@ const createConsoleQueryClient = () => }) const TestWrapper = ({ children }: { children: ReactNode }) => { - const queryClient = createConsoleQueryClient() + const [queryClient] = useState(() => { + const client = createConsoleQueryClient() + if (recordsData) + client.setQueryData( + consoleQuery.datasets.byDatasetId.queries.get.queryKey({ + input: { params: { dataset_id: 'dataset-1' }, query: { limit: 10, page: 1 } }, + }), + recordsData, + ) + return client + }) seedAccountProfileQuery(queryClient, mockConsoleState.userProfile) - return {children} + return ( + + + {children} + + + ) } const renderWithProviders = (ui: React.ReactElement) => { @@ -365,7 +387,7 @@ const _createMockRetrievalConfig = (overrides = {}): RetrievalConfig => describe('HitTestingPage', () => { beforeEach(async () => { vi.clearAllMocks() - mockRecordsRefetch.mockReset() + getRecords.mockReset() mockHitTestingMutateAsync.mockReset() mockExternalHitTestingMutateAsync.mockReset() Object.assign(mockDataset, { @@ -378,19 +400,8 @@ describe('HitTestingPage', () => { userProfile: { id: 'user-1' }, workspacePermissionKeys: [], } - mockRecordsQuery.mockReturnValue({ - data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, - refetch: mockRecordsRefetch, - isLoading: false, - }) - vi.mocked(mockHitTestingState).mockReturnValue({ - mutateAsync: mockHitTestingMutateAsync, - isLoading: false, - }) - vi.mocked(mockExternalHitTestingState).mockReturnValue({ - mutateAsync: mockExternalHitTestingMutateAsync, - isLoading: false, - }) + recordsData = { data: [], total: 0, page: 1, limit: 10, has_more: false } + getRecords.mockImplementation(() => Promise.resolve(recordsData)) const useBreakpoints = await import('@/hooks/use-breakpoints') vi.mocked(useBreakpoints.default).mockReturnValue( @@ -404,20 +415,12 @@ describe('HitTestingPage', () => { renderWithProviders() expect(screen.queryByRole('textbox')).not.toBeInTheDocument() - expect(mockRecordsQuery).toHaveBeenCalledWith( - expect.objectContaining({ - input: { params: { dataset_id: 'dataset-1' }, query: { limit: 10, page: 1 } }, - enabled: false, - }), - ) + expect(getRecords).not.toHaveBeenCalled() }) it('shows the records loading state', async () => { - mockRecordsQuery.mockReturnValue({ - data: undefined, - refetch: mockRecordsRefetch, - isLoading: true, - }) + recordsData = undefined + getRecords.mockReturnValue(new Promise(() => {})) renderWithProviders() @@ -437,33 +440,19 @@ describe('HitTestingPage', () => { 'announces loading and failure in the same status region for %s retrieval', async (provider) => { mockDataset.provider = provider - const setRetrievalState = (state: { isPending: boolean; isError: boolean }) => { - if (provider === 'external') { - vi.mocked(mockExternalHitTestingState).mockReturnValue({ - mutateAsync: mockExternalHitTestingMutateAsync, - ...state, - }) - } else { - vi.mocked(mockHitTestingState).mockReturnValue({ - mutateAsync: mockHitTestingMutateAsync, - ...state, - }) - } - } - const { rerender } = renderWithProviders() + const response = createDeferred() + const request = + provider === 'external' ? mockExternalHitTestingMutateAsync : mockHitTestingMutateAsync + request.mockReturnValue(response.promise) + renderWithProviders() + const user = userEvent.setup() const status = screen.getByRole('status') - - setRetrievalState({ isPending: true, isError: false }) - rerender() - - expect(status).toHaveTextContent('common.loading') + await user.type(screen.getByRole('textbox'), 'Test query') + await user.click(screen.getByRole('button', { name: /input.testing/ })) + await waitFor(() => expect(status).toHaveTextContent('common.loading')) + await act(async () => response.reject(new Error('Retrieval failed'))) + await waitFor(() => expect(status).toHaveTextContent('common.api.actionFailed')) expect(screen.getByRole('status')).toBe(status) - - setRetrievalState({ isPending: false, isError: true }) - rerender() - - expect(screen.getByRole('status')).toBe(status) - expect(status).toHaveTextContent('common.api.actionFailed') expect(status).not.toHaveTextContent('common.loading') }, ) @@ -476,50 +465,25 @@ describe('HitTestingPage', () => { query: { content: 'Test query' }, records: [createMockHitTesting()], } - mockHitTestingMutateAsync.mockImplementationOnce(async (_params, options) => { - options?.onSuccess?.(response) - return response - }) - const { rerender } = renderWithProviders() + mockHitTestingMutateAsync.mockResolvedValueOnce(response) + renderWithProviders() await user.type(screen.getByRole('textbox'), 'Test query') await user.click(screen.getByRole('button', { name: /input.testing/ })) expect(await screen.findByRole('dialog', { name: '1 Retrieved Chunks' })).toBeInTheDocument() - vi.mocked(mockHitTestingState).mockReturnValue({ - mutateAsync: mockHitTestingMutateAsync, - isPending: true, - isError: false, - }) - rerender() - - const pendingDialog = screen.getByRole('dialog', { name: 'datasetHitTesting.title' }) - expect(within(pendingDialog).queryByText('1 Retrieved Chunks')).not.toBeInTheDocument() - - vi.mocked(mockHitTestingState).mockReturnValue({ - mutateAsync: mockHitTestingMutateAsync, - isPending: false, - isError: true, - }) - rerender() - - const failedDialog = screen.getByRole('dialog', { name: 'datasetHitTesting.title' }) - expect(within(failedDialog).getByText('common.api.actionFailed')).toBeInTheDocument() - expect(within(failedDialog).queryByText('1 Retrieved Chunks')).not.toBeInTheDocument() - expect(within(failedDialog).queryByText('test-document.pdf')).not.toBeInTheDocument() - - await user.click(within(failedDialog).getByRole('button', { name: /operation.close/ })) - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) - vi.mocked(mockHitTestingState).mockReturnValue({ - mutateAsync: mockHitTestingMutateAsync, - isPending: false, - isError: false, - }) - mockHitTestingMutateAsync.mockImplementationOnce(async (_params, options) => { - const emptyResponse = { ...response, records: [] } - options?.onSuccess?.(emptyResponse) - return emptyResponse - }) - rerender() + await user.click(screen.getByRole('button', { name: /operation.close/ })) + const nextResponse = createDeferred() + mockHitTestingMutateAsync.mockReturnValueOnce(nextResponse.promise) + await user.click(screen.getByRole('button', { name: /input.testing/ })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('common.loading')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('1 Retrieved Chunks')).not.toBeInTheDocument() + await act(async () => nextResponse.reject(new Error('Retrieval failed'))) + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent('common.api.actionFailed'), + ) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + mockHitTestingMutateAsync.mockResolvedValueOnce({ ...response, records: [] }) await user.click(screen.getByRole('button', { name: /input.testing/ })) expect(await screen.findByRole('dialog', { name: '0 Retrieved Chunks' })).toBeInTheDocument() }) @@ -530,65 +494,52 @@ describe('HitTestingPage', () => { query: { content: 'No match' }, records: [], } - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(response) - return response - }) + mockHitTestingMutateAsync.mockResolvedValue(response) renderWithProviders() const status = screen.getByRole('status') await user.type(screen.getByRole('textbox'), 'No match') await user.click(screen.getByRole('button', { name: /input.testing/ })) - expect(status).toHaveTextContent('0 Retrieved Chunks') + await waitFor(() => expect(status).toHaveTextContent('0 Retrieved Chunks')) }) it('loads a history record into the query input when selected', async () => { const user = userEvent.setup() - mockRecordsQuery.mockReturnValue({ - data: { - data: [ - createMockRecord({ - queries: [ - { content: 'Record query text', content_type: 'text_query', file_info: null }, - ], - }), - ], - total: 1, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - }) + recordsData = { + data: [ + createMockRecord({ + queries: [{ content: 'Record query text', content_type: 'text_query', file_info: null }], + }), + ], + total: 1, + page: 1, + limit: 10, + has_more: false, + } renderWithProviders() - await user.click(screen.getByText('Record query text')) + await user.click(await screen.findByText('Record query text')) expect(screen.getByRole('textbox')).toHaveValue('Record query text') }) it('requests the next records page through pagination', async () => { const user = userEvent.setup() - mockRecordsQuery.mockReturnValue({ - data: { - data: Array.from({ length: 10 }, (_, index) => createMockRecord({ id: `record-${index}` })), - total: 25, - page: 1, - limit: 10, - has_more: true, - }, - refetch: mockRecordsRefetch, - isLoading: false, - }) + recordsData = { + data: Array.from({ length: 10 }, (_, index) => createMockRecord({ id: `record-${index}` })), + total: 25, + page: 1, + limit: 10, + has_more: true, + } renderWithProviders() await user.click(screen.getByRole('button', { name: /pagination.next/ })) - expect(mockRecordsQuery).toHaveBeenLastCalledWith( - expect.objectContaining({ - input: { params: { dataset_id: 'dataset-1' }, query: { limit: 10, page: 2 } }, - enabled: true, + await waitFor(() => + expect(getRecords).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1' }, + query: { limit: 10, page: 2 }, }), ) }) @@ -599,10 +550,7 @@ describe('HitTestingPage', () => { query: { content: 'Test query' }, records: [createMockHitTesting()], } - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(response) - return response - }) + mockHitTestingMutateAsync.mockResolvedValue(response) renderWithProviders() await user.click(screen.getByText(/semantic_search/)) @@ -617,17 +565,19 @@ describe('HitTestingPage', () => { await waitFor(() => { expect(mockHitTestingMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ - query: 'Test query', - attachment_ids: [], - retrieval_model: expect.objectContaining({ - search_method: RETRIEVE_METHOD.semantic, - top_k: 8, + params: { dataset_id: 'dataset-1' }, + body: expect.objectContaining({ + query: 'Test query', + attachment_ids: [], + retrieval_model: expect.objectContaining({ + search_method: RETRIEVE_METHOD.semantic, + top_k: 8, + }), }), }), - expect.objectContaining({ onSuccess: expect.any(Function) }), ) }) - expect(mockRecordsRefetch).toHaveBeenCalledTimes(1) + expect(getRecords).toHaveBeenCalledTimes(1) expect(await screen.findByText('test-document.pdf')).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('1 Retrieved Chunks') expect( @@ -649,10 +599,7 @@ describe('HitTestingPage', () => { }, ], } - mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(response) - return response - }) + mockExternalHitTestingMutateAsync.mockResolvedValue(response) renderWithProviders() await user.click(screen.getByRole('button', { name: /settingTitle/ })) @@ -662,8 +609,9 @@ describe('HitTestingPage', () => { await user.click(screen.getByRole('button', { name: /input.testing/ })) await waitFor(() => { - expect(mockExternalHitTestingMutateAsync).toHaveBeenCalledWith( - { + expect(mockExternalHitTestingMutateAsync).toHaveBeenCalledWith({ + params: { dataset_id: 'dataset-1' }, + body: { query: 'External query', external_retrieval_model: { top_k: 8, @@ -671,38 +619,10 @@ describe('HitTestingPage', () => { score_threshold_enabled: false, }, }, - expect.objectContaining({ onSuccess: expect.any(Function) }), - ) + }) }) expect(mockHitTestingMutateAsync).not.toHaveBeenCalled() - expect(mockRecordsRefetch).toHaveBeenCalledTimes(1) + expect(getRecords).toHaveBeenCalledTimes(1) expect(await screen.findByText('External content')).toBeInTheDocument() }) }) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useMutation: (options: { mutationKey: [string, string] }) => { - if (options.mutationKey[0] === 'hit-testing') - return mockHitTestingState() ?? { mutateAsync: mockHitTestingMutateAsync, isPending: false } - return ( - mockExternalHitTestingState() ?? { - mutateAsync: mockExternalHitTestingMutateAsync, - isPending: false, - } - ) - }, - useQuery: (options: { - queryKey: OperationKey< - 'query', - { params: GetWorkspacesCurrentModelsModelTypesByModelTypeData['path'] } - > - }) => { - if (options.queryKey[0].includes('modelTypes')) return { data: [], isPending: false } - if (options.queryKey[0].includes('queries')) return mockRecordsQuery(options) - return actual.useQuery(options) - }, - } -}) diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx index 8fd2a2b88b5f3a..f86f75d322564f 100644 --- a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx @@ -27,10 +27,6 @@ vi.mock('@/app/notifications', () => ({ toast: mockToast, })) -vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ - isReRankModelSelected: vi.fn(() => true), -})) - vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ default: ({ value, @@ -59,10 +55,6 @@ vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: () => 'model-name', })) -vi.mock('../../settings/utils', () => ({ - checkShowMultiModalTip: () => false, -})) - describe('ModifyRetrievalModal', () => { const defaultProps = { indexMethod: 'high_quality', diff --git a/web/app/components/datasets/hit-testing/components/query-input/index.tsx b/web/app/components/datasets/hit-testing/components/query-input/index.tsx index 2b825b7031f98c..96dc672978901b 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/index.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/index.tsx @@ -3,7 +3,7 @@ import type { ExternalHitTestingResponse, HitTestingResponse, } from '@dify/contracts/api/console/datasets/types.gen' -import type { UseMutateAsyncFunction } from '@tanstack/react-query' +import type { UseMutateFunction } from '@tanstack/react-query' import type { ChangeEvent } from 'react' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { @@ -39,8 +39,8 @@ type QueryInputProps = { isEconomy: boolean canRunRetrievalRecall?: boolean onSubmit?: () => void - hitTestingMutation: UseMutateAsyncFunction - externalKnowledgeBaseHitTestingMutation: UseMutateAsyncFunction< + hitTestingMutation: UseMutateFunction + externalKnowledgeBaseHitTestingMutation: UseMutateFunction< ExternalHitTestingResponse, Error, ExternalKnowledgeBaseHitTestingRequest, @@ -159,10 +159,10 @@ const QueryInput = ({ [queries, setQueries], ) - const onSubmit = useCallback(async () => { + const onSubmit = useCallback(() => { if (isSubmitDisabled || loading) return - await hitTestingMutation( + hitTestingMutation( { query: text, attachment_ids: images.map((image) => image.uploadedId), @@ -192,10 +192,10 @@ const QueryInput = ({ setHitResult, ]) - const externalRetrievalTestingOnSubmit = useCallback(async () => { + const externalRetrievalTestingOnSubmit = useCallback(() => { if (isSubmitDisabled || loading) return - await externalKnowledgeBaseHitTestingMutation( + externalKnowledgeBaseHitTestingMutation( { query: text, external_retrieval_model: { diff --git a/web/app/components/datasets/hit-testing/index.tsx b/web/app/components/datasets/hit-testing/index.tsx index 0d6ceed192f834..f191a3d865d546 100644 --- a/web/app/components/datasets/hit-testing/index.tsx +++ b/web/app/components/datasets/hit-testing/index.tsx @@ -104,7 +104,7 @@ const HitTestingPage: FC = ({ datasetId }: Props) => { ] = useBoolean(!isMobile) const { - mutateAsync: hitTestingMutation, + mutate: hitTestingMutation, isPending: isHitTestingPending, isError: isHitTestingError, } = useMutation({ @@ -116,7 +116,7 @@ const HitTestingPage: FC = ({ datasetId }: Props) => { }), }) const { - mutateAsync: externalKnowledgeBaseHitTestingMutation, + mutate: externalKnowledgeBaseHitTestingMutation, isPending: isExternalKnowledgeBaseHitTestingPending, isError: isExternalKnowledgeBaseHitTestingError, } = useMutation({ diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index fe131f3f359a48..953d390a01de8e 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -1,15 +1,12 @@ -import type { ReactElement, ReactNode } from 'react' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { createQueryClientWrapper } from '@/test/console/query-client' -import { render as renderWithConsoleState } from '@/test/console/render' -import { createTestQueryClient } from '@/test/query-client' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' +import { createDatasetFixture } from '@/test/fixtures/dataset' import List from '../index' -const render = (ui: ReactElement) => - renderWithConsoleState(ui, { wrapper: createQueryClientWrapper(createTestQueryClient()) }) - const mockPush = vi.fn() const mockReplace = vi.fn() let mockConsoleState = { @@ -56,26 +53,25 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) -const { mockDatasetListQuery } = vi.hoisted(() => ({ - mockDatasetListQuery: vi.fn((_query: { include_all?: boolean; keyword?: string }) => ({ - data: { pages: [{ data: [], total: 1 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - })), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - // The page reads the list's first request. - 'datasets.get': (options: { input: (page: number) => { query: object } }) => - mockDatasetListQuery(options.input(1).query), - 'datasets.apiBaseInfo.get': () => ({ - data: { api_base_url: 'https://api.example.com' }, +const { getDatasets } = vi.hoisted(() => ({ getDatasets: vi.fn() })) +const datasetPage = (total = 1) => ({ + data: total ? [createDatasetFixture()] : [], + total, + page: 1, + limit: 30, + has_more: false, +}) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.get': getDatasets, + 'datasets.apiBaseInfo.get': () => + Promise.resolve({ api_base_url: 'https://api.example.com' }), }), - }) + } }) // Mock Datasets component @@ -172,13 +168,7 @@ describe('List', () => { isCurrentWorkspaceOwner: true, workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'], } - mockDatasetListQuery.mockReturnValue({ - data: { pages: [{ data: [], total: 1 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - }) + getDatasets.mockResolvedValue(datasetPage(1)) }) describe('Rendering', () => { @@ -215,31 +205,31 @@ describe('List', () => { it('should query datasets with includeAll disabled initially', async () => { render() - expect(mockDatasetListQuery).toHaveBeenCalledWith( - expect.objectContaining({ + expect(getDatasets).toHaveBeenCalledWith({ + query: expect.objectContaining({ include_all: false, }), - ) + }) }) it('should query datasets with empty keywords initially', async () => { render() - expect(mockDatasetListQuery).toHaveBeenCalledWith( - expect.objectContaining({ + expect(getDatasets).toHaveBeenCalledWith({ + query: expect.objectContaining({ keyword: '', }), - ) + }) }) it('should query datasets with empty tags initially', async () => { render() - expect(mockDatasetListQuery).toHaveBeenCalledWith( - expect.objectContaining({ + expect(getDatasets).toHaveBeenCalledWith({ + query: expect.objectContaining({ tag_ids: [], }), - ) + }) }) }) @@ -287,17 +277,11 @@ describe('List', () => { }) it('should render first empty state when there are no datasets and no active filters', async () => { - mockDatasetListQuery.mockReturnValue({ - data: { pages: [{ data: [], total: 0 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - }) + getDatasets.mockResolvedValue(datasetPage(0)) render() - expect(screen.getByText('dataset.firstEmpty.title')).toBeInTheDocument() + expect(await screen.findByText('dataset.firstEmpty.title')).toBeInTheDocument() expect(screen.queryByTestId('datasets-component')).not.toBeInTheDocument() }) @@ -307,17 +291,11 @@ describe('List', () => { isCurrentWorkspaceOwner: true, workspacePermissionKeys: ['dataset.create_and_management'], } - mockDatasetListQuery.mockReturnValue({ - data: { pages: [{ data: [], total: 0 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - }) + getDatasets.mockResolvedValue(datasetPage(0)) render() - expect(screen.getByText('dataset.firstEmpty.title')).toBeInTheDocument() + expect(await screen.findByText('dataset.firstEmpty.title')).toBeInTheDocument() expect( screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ }), ).toHaveAttribute('href', '/datasets/create-from-pipeline') @@ -329,29 +307,17 @@ describe('List', () => { isCurrentWorkspaceOwner: true, workspacePermissionKeys: [], } - mockDatasetListQuery.mockReturnValue({ - data: { pages: [{ data: [], total: 0 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - }) + getDatasets.mockResolvedValue(datasetPage(0)) render() expect(screen.queryByText('dataset.firstEmpty.title')).not.toBeInTheDocument() - expect(screen.getByText('dataset.firstEmpty.noCreatePermission')).toBeInTheDocument() + expect(await screen.findByText('dataset.firstEmpty.noCreatePermission')).toBeInTheDocument() expect(screen.queryByTestId('datasets-component')).not.toBeInTheDocument() }) it('should not render first empty state before the first dataset page resolves', async () => { - mockDatasetListQuery.mockReturnValue({ - data: { pages: [] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - }) + getDatasets.mockReturnValue(new Promise(() => {})) render() @@ -360,64 +326,42 @@ describe('List', () => { }) it('should keep the regular list for empty filtered results', async () => { - mockDatasetListQuery.mockImplementation((params) => ({ - data: { pages: [{ data: [], total: params.include_all ? 0 : 1 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - })) + getDatasets.mockImplementation(({ query }) => + Promise.resolve(datasetPage(query.include_all ? 0 : 1)), + ) render() fireEvent.click(screen.getByTestId('include-all-checkbox')) expect(screen.getByTestId('datasets-component')).toBeInTheDocument() - expect(screen.getByRole('status')).toHaveTextContent('dataset.filterEmpty.noKnowledge') + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent('dataset.filterEmpty.noKnowledge'), + ) expect(screen.queryByText('dataset.firstEmpty.title')).not.toBeInTheDocument() }) it('announces empty search results only after the current filters finish loading', async () => { const user = userEvent.setup() - const result = { - data: { pages: [{ data: [], total: 1 }] }, - fetchNextPage: vi.fn(), - hasNextPage: false, - isFetching: false, - isFetchingNextPage: false, - isPlaceholderData: false, - isError: false, - } - mockDatasetListQuery.mockImplementation(() => result) - const { rerender } = render() + const response = createDeferred>() + getDatasets.mockResolvedValueOnce(datasetPage()).mockReturnValue(response.promise) + render() + await waitFor(() => expect(screen.getByTestId('dataset-total')).toHaveTextContent('1')) const status = screen.getByRole('status') const search = screen.getByRole('searchbox') - expect(status).toBeEmptyDOMElement() - await user.type(search, 'missing') expect(status).toBeEmptyDOMElement() await waitFor(() => - expect(mockDatasetListQuery).toHaveBeenLastCalledWith( - expect.objectContaining({ keyword: 'missing' }), - ), + expect(getDatasets).toHaveBeenLastCalledWith({ + query: expect.objectContaining({ keyword: 'missing' }), + }), ) - - result.data.pages = [{ data: [], total: 0 }] - result.isFetching = true - result.isPlaceholderData = true - rerender() + expect(screen.getByTestId('dataset-total')).toHaveTextContent('1') expect(status).toBeEmptyDOMElement() - - result.isPlaceholderData = false - rerender() - expect(status).toBeEmptyDOMElement() - - result.isFetching = false - rerender() + await act(async () => response.resolve(datasetPage(0))) + await waitFor(() => expect(status).toHaveTextContent('dataset.filterEmpty.noKnowledge')) expect(screen.getByRole('status')).toBe(status) - expect(status).toHaveTextContent('dataset.filterEmpty.noKnowledge') expect(search).toHaveFocus() - await user.type(search, ' again') expect(status).toBeEmptyDOMElement() }) diff --git a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts index f97f2ff6f8c552..b5915c59cc7fa5 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts @@ -19,11 +19,15 @@ vi.mock('@/service/base', () => ({ sseGeneratorPost: vi.fn(), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.byPipelineId.exports.get': () => ({ mutateAsync: vi.fn() }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.byPipelineId.exports.get': (input) => vi.fn()(input), + }), + } }) const dataset = { diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx index ec5f3fb5cc8c87..d8c2a384a39a9e 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx @@ -1,37 +1,30 @@ import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { DataType, UpdateType } from '../../types' import EditMetadataBatchModal from '../modal' // Mock service/API calls const mockDoAddMetaData = vi.fn().mockResolvedValue({}) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.metadata.post': () => ({ - mutateAsync: mockDoAddMetaData, - }), - 'datasets.byDatasetId.metadata.get': () => ({ - data: { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.metadata.post': (input) => mockDoAddMetaData(input), + 'datasets.byDatasetId.metadata.get': async () => ({ doc_metadata: [ { id: 'existing-1', name: 'existing_field', type: DataType.string }, { id: 'existing-2', name: 'another_field', type: DataType.number }, ], - }, + }), }), - }) + } }) -// Mock check name hook to control validation -let mockCheckNameResult = { errorMsg: '' } -vi.mock('../../hooks/use-check-metadata-name', () => ({ - default: () => ({ - checkName: () => mockCheckNameResult, - }), -})) - // Mock Toast to verify notifications const mockToastNotify = vi.fn() vi.mock('@/app/notifications', () => ({ @@ -119,7 +112,6 @@ describe('EditMetadataBatchModal', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckNameResult = { errorMsg: '' } }) const getEditRows = () => screen.getAllByRole('group', { name: /^Edit metadata / }) @@ -351,8 +343,6 @@ describe('EditMetadataBatchModal', () => { }) it('should call doAddMetaData when saving new metadata with valid name', async () => { - mockCheckNameResult = { errorMsg: '' } - render() await waitFor(() => { @@ -367,8 +357,6 @@ describe('EditMetadataBatchModal', () => { }) it('should show success toast when saving with valid name', async () => { - mockCheckNameResult = { errorMsg: '' } - render() await waitFor(() => { @@ -390,22 +378,21 @@ describe('EditMetadataBatchModal', () => { }) }) - it('should show error toast when saving with invalid name', async () => { - mockCheckNameResult = { errorMsg: 'Name already exists' } - + it('should reject an invalid metadata name without sending a request', async () => { render() await waitFor(() => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - await createMetadata() + await createMetadata('Invalid name') + expect(mockDoAddMetaData).not.toHaveBeenCalled() await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', - message: 'Name already exists', + message: 'dataset.metadata.checkName.invalid', }), ) }) diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts index 634bd60798a177..0b828ad5cc16b6 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts @@ -1,5 +1,6 @@ -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { DataType, UpdateType } from '../../types' import useBatchEditDocumentMetadata from '../use-batch-edit-document-metadata' @@ -28,13 +29,15 @@ type MetadataItemWithEdit = { // Mock useBatchUpdateDocMetadata const mockMutateAsync = vi.fn().mockResolvedValue({}) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.metadata.post': () => ({ - mutateAsync: mockMutateAsync, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.metadata.post': (input) => mockMutateAsync(input), }), - }) + } }) vi.mock('@/app/notifications', () => ({ diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts index 77150858d2d455..8e5e1f9e5d4e2a 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts @@ -1,5 +1,6 @@ -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { DataType } from '../../types' import useEditDatasetMetadata from '../use-edit-dataset-metadata' @@ -8,39 +9,32 @@ const mockDoRenameMetaData = vi.fn().mockResolvedValue({}) const mockDoDeleteMetaData = vi.fn().mockResolvedValue({}) const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({}) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.metadata.get': () => ({ - data: { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.metadata.get': async () => ({ doc_metadata: [ { id: '1', name: 'field_one', type: DataType.string, count: 5 }, { id: '2', name: 'field_two', type: DataType.number, count: 3 }, ], built_in_field_enabled: false, - }, - }), - 'datasets.byDatasetId.metadata.post': () => ({ - mutateAsync: mockDoAddMetaData, - }), - 'datasets.byDatasetId.metadata.byMetadataId.patch': () => ({ - mutateAsync: mockDoRenameMetaData, - }), - 'datasets.byDatasetId.metadata.byMetadataId.delete': () => ({ - mutateAsync: mockDoDeleteMetaData, - }), - 'datasets.byDatasetId.metadata.builtIn.byAction.post': () => ({ - mutateAsync: mockToggleBuiltInStatus, - }), - 'datasets.metadata.builtIn.get': () => ({ - data: { + }), + 'datasets.byDatasetId.metadata.post': (input) => mockDoAddMetaData(input), + 'datasets.byDatasetId.metadata.byMetadataId.patch': (input) => mockDoRenameMetaData(input), + 'datasets.byDatasetId.metadata.byMetadataId.delete': (input) => mockDoDeleteMetaData(input), + 'datasets.byDatasetId.metadata.builtIn.byAction.post': (input) => + mockToggleBuiltInStatus(input), + 'datasets.metadata.builtIn.get': async () => ({ fields: [ { name: 'created_at', type: DataType.time }, { name: 'modified_at', type: DataType.time }, ], - }, + }), }), - }) + } }) vi.mock('@/app/notifications', () => ({ @@ -56,13 +50,6 @@ vi.mock('@/app/notifications', () => ({ })) // Mock useCheckMetadataName -vi.mock('../use-check-metadata-name', () => ({ - default: () => ({ - checkName: (name: string) => ({ - errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name', - }), - }), -})) // Mock localStorage const localStorageMock = { @@ -100,9 +87,9 @@ describe('useEditDatasetMetadata', () => { expect(typeof result.current.hideEditModal).toBe('function') }) - it('should return datasetMetaData', () => { + it('should return datasetMetaData after loading', async () => { const { result } = renderHook(() => useEditDatasetMetadata(defaultProps)) - expect(result.current.datasetMetaData).toBeDefined() + await waitFor(() => expect(result.current.datasetMetaData).toBeDefined()) }) it('should return handleAddMetaData function', () => { @@ -120,14 +107,14 @@ describe('useEditDatasetMetadata', () => { expect(typeof result.current.handleDeleteMetaData).toBe('function') }) - it('should return builtInMetaData', () => { + it('should return builtInMetaData after loading', async () => { const { result } = renderHook(() => useEditDatasetMetadata(defaultProps)) - expect(result.current.builtInMetaData).toBeDefined() + await waitFor(() => expect(result.current.builtInMetaData).toBeDefined()) }) - it('should return builtInEnabled', () => { + it('should return builtInEnabled after loading', async () => { const { result } = renderHook(() => useEditDatasetMetadata(defaultProps)) - expect(typeof result.current.builtInEnabled).toBe('boolean') + await waitFor(() => expect(typeof result.current.builtInEnabled).toBe('boolean')) }) it('should return setBuiltInEnabled function', () => { diff --git a/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts index 513d1eca1573ad..ca36b59b286ae8 100644 --- a/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts @@ -1,5 +1,6 @@ -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { DataType } from '../../types' import useMetadataDocument from '../use-metadata-document' @@ -16,30 +17,26 @@ type DocDetail = { const mockMutateAsync = vi.fn().mockResolvedValue({}) const mockDoAddMetaData = vi.fn().mockResolvedValue({}) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.metadata.post': () => ({ - mutateAsync: mockMutateAsync, - }), - 'datasets.byDatasetId.metadata.post': () => ({ - mutateAsync: mockDoAddMetaData, - }), - 'datasets.byDatasetId.documents.byDocumentId.get': () => ({ - data: { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.metadata.post': (input) => mockMutateAsync(input), + 'datasets.byDatasetId.metadata.post': (input) => mockDoAddMetaData(input), + 'datasets.byDatasetId.documents.byDocumentId.get': async () => ({ doc_metadata: [ { id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' }, { id: '2', name: 'field_two', type: DataType.number, value: 42 }, { id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }, ], - }, - }), - 'datasets.byDatasetId.metadata.get': () => ({ - data: { + }), + 'datasets.byDatasetId.metadata.get': async () => ({ built_in_field_enabled: true, - }, + }), }), - }) + } }) // Mock useDatasetDetailContext @@ -97,13 +94,6 @@ vi.mock('@/app/notifications', () => ({ })) // Mock useCheckMetadataName -vi.mock('../use-check-metadata-name', () => ({ - default: () => ({ - checkName: (name: string) => ({ - errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name', - }), - }), -})) describe('useMetadataDocument', () => { const mockDocDetail: DocDetail = { @@ -169,9 +159,9 @@ describe('useMetadataDocument', () => { expect(result.current.hasData).toBe(result.current.list.length > 0) }) - it('should return builtInEnabled', () => { + it('should return builtInEnabled after loading', async () => { const { result } = renderHook(() => useMetadataDocument(defaultProps)) - expect(typeof result.current.builtInEnabled).toBe('boolean') + await waitFor(() => expect(typeof result.current.builtInEnabled).toBe('boolean')) }) it('should return originInfo', () => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index 953c34ddff680f..204868dae36e8c 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -1,28 +1,26 @@ import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { DataType } from '../../types' import DatasetMetadataDrawer from '../dataset-metadata-drawer' // Mock service/API calls -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.metadata.get': () => ({ - data: { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.metadata.get': async () => ({ doc_metadata: [{ id: '1', name: 'existing_field', type: DataType.string }], - }, + }), }), - }) + } }) // Mock check name hook -vi.mock('../../hooks/use-check-metadata-name', () => ({ - default: () => ({ - checkName: () => ({ errorMsg: '' }), - }), -})) const mockToastNotify = vi.fn() vi.mock('@/app/notifications', () => ({ diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx index 51bb7aab4804d6..0b0d30188cbd65 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/document-information.spec.tsx @@ -1,19 +1,24 @@ -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import MetadataDocument from '../index' vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContext: () => ({ dataset: { embedding_available: true } }), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.documents.metadata.post': () => ({ mutateAsync: vi.fn() }), - 'datasets.byDatasetId.metadata.post': () => ({ mutateAsync: vi.fn() }), - 'datasets.byDatasetId.documents.byDocumentId.get': () => ({ data: { doc_metadata: [] } }), - 'datasets.byDatasetId.metadata.get': () => ({ data: { built_in_field_enabled: false } }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.documents.metadata.post': (input) => vi.fn()(input), + 'datasets.byDatasetId.metadata.post': (input) => vi.fn()(input), + 'datasets.byDatasetId.documents.byDocumentId.get': async () => ({ doc_metadata: [] }), + 'datasets.byDatasetId.metadata.get': async () => ({ built_in_field_enabled: false }), + }), + } }) vi.mock('@/next/navigation', () => ({ diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx index 4257edfc761328..350d2d26cff731 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx @@ -1,6 +1,7 @@ import type { MetadataItemWithValue } from '../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { DataType } from '../../types' import MetadataDocument from '../index' @@ -30,23 +31,20 @@ vi.mock('../../hooks/use-metadata-document', () => ({ })) // Mock service calls -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.metadata.get': () => ({ - data: { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.metadata.get': async () => ({ doc_metadata: [], - }, + }), }), - }) + } }) // Mock check name hook -vi.mock('../../hooks/use-check-metadata-name', () => ({ - default: () => ({ - checkName: () => ({ errorMsg: '' }), - }), -})) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index 6a3ebddb1b50b6..7857279dd237f8 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -1,6 +1,7 @@ import type { MetadataItemWithValue } from '../../types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { DataType } from '../../types' import InfoGroup from '../info-group' @@ -21,15 +22,17 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'datasets.byDatasetId.metadata.get': () => ({ - data: { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'datasets.byDatasetId.metadata.get': async () => ({ doc_metadata: [{ id: '1', name: 'test', type: DataType.string }], - }, + }), }), - }) + } }) // Mock useTimestamp diff --git a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx index 10c08dd7659721..ce65fc3c2692bb 100644 --- a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -163,11 +163,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useModelModalHandler: () => vi.fn(), })) -vi.mock('@/app/components/datasets/common/check-rerank-model', async (importOriginal) => ({ - ...(await importOriginal()), - isReRankModelSelected: () => true, -})) - vi.mock('@/app/notifications', () => ({ toast: { error: mockToastError, diff --git a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts index 76e53309ba80ba..652bcd7eadd587 100644 --- a/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts +++ b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts @@ -140,11 +140,6 @@ vi.mock('@/service/datasets', () => ({ updateDatasetSetting: vi.fn().mockResolvedValue({}), })) -vi.mock('@/app/components/datasets/common/check-rerank-model', async (importOriginal) => ({ - ...(await importOriginal()), - isReRankModelSelected: () => true, -})) - vi.mock('@/app/notifications', () => ({ toast: { success: mockToastSuccess, @@ -638,6 +633,7 @@ describe('useFormState', () => { const configWithWeights: RetrievalConfig = { ...result.current.retrievalConfig, search_method: RETRIEVE_METHOD.hybrid, + reranking_mode: RerankingModeEnum.WeightedScore, weights: { weight_type: WeightedScoreEnum.Customized, vector_setting: { @@ -909,7 +905,14 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }, }))() if (!options.queryKey[0].includes('modelTypes')) return actual.useQuery(options) - return { data: [] } + return { + data: [ + { + provider: 'langgenius/xinference/xinference', + models: [{ model: 'bge-reranker-v2-m3', features: [] }], + }, + ], + } }, } }) diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx index 2cd765331ae44a..349d9d691ce8c5 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -1,22 +1,15 @@ import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen' import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen' import type { ComponentProps, ReactElement, ReactNode } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { - act, - cleanup, - fireEvent, - render as rtlRender, - screen, - waitFor, -} from '@testing-library/react' +import { QueryClientProvider } from '@tanstack/react-query' +import { act, cleanup, render as rtlRender, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import TryAppComponent from '../index' -import { TypeEnum } from '../types' -function render(ui: ReactElement) { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +function render(ui: ReactElement, queryClient = createConsoleQueryClient()) { return rtlRender(ui, { wrapper: ({ children }: { children: ReactNode }) => ( {children} @@ -46,26 +39,19 @@ function TryApp({ ) } -const mockDetailQueryResult = vi.fn() -const mockPreviewSuspension = vi.hoisted(() => ({ promise: null as Promise | null })) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() +const getDetail = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') return { ...actual, - useQuery: (options: Parameters[0]) => { - const appId = ['test-app-id', 'my-specific-app-id', 'my-app-id'].find( - (id) => - JSON.stringify(options.queryKey) === - JSON.stringify( - consoleQuery.trialApps.byAppId.get.queryKey({ input: { params: { app_id: id } } }), - ), - ) - if (appId) return mockDetailQueryResult(appId) - return actual.useQuery(options) - }, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'trialApps.byAppId.get': (input: { params: { app_id: string } }) => + getDetail(input.params.app_id), + }), } }) +const mockPreviewSuspension = vi.hoisted(() => ({ promise: null as Promise | null })) vi.mock('../app', () => ({ default: ({ appId, appDetail }: { appId: string; appDetail: TrialAppDetailResponse }) => ( @@ -145,483 +131,192 @@ const createMockAppDetail = (mode: string = 'chat'): TrialAppDetailResponse => }, }) as unknown as TrialAppDetailResponse -describe('TryApp (main index.tsx)', () => { +describe('TryApp', () => { beforeEach(() => { - vi.clearAllMocks() mockPreviewSuspension.promise = null - // Suppress expected React act() warnings from internal async state updates - vi.spyOn(console, 'error').mockImplementation(() => {}) - mockDetailQueryResult.mockReturnValue({ - data: createMockAppDetail(), - isLoading: false, - }) + getDetail.mockReset().mockResolvedValue(createMockAppDetail()) }) - - afterEach(() => { - cleanup() - vi.restoreAllMocks() + afterEach(cleanup) + + it('keeps the dialog and tabs mounted while the initial request settles', async () => { + const request = createDeferred() + getDetail.mockReturnValue(request.promise) + render() + const dialog = screen.getByRole('dialog') + const detailTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' }) + const tryTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' }) + expect(screen.getByRole('progressbar')).toBeInTheDocument() + expect(tryTab).toHaveAttribute('aria-disabled', 'true') + await act(async () => request.resolve(createMockAppDetail())) + expect(await screen.findByTestId('preview-component')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBe(dialog) + expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toBe(detailTab) + expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' })).toBe(tryTab) + expect(tryTab).not.toHaveAttribute('aria-disabled', 'true') + expect(detailTab).toHaveAttribute('aria-selected', 'true') }) - describe('loading state', () => { - it('renders loading when isLoading is true', () => { - mockDetailQueryResult.mockReturnValue({ - data: null, - isLoading: true, - }) - - render() - - expect(screen.queryByRole('progressbar')).toBeInTheDocument() - }) - - it('keeps the dialog and tabs mounted when the initial load finishes', () => { - let result = { data: null as TrialAppDetailResponse | null, isLoading: true } - mockDetailQueryResult.mockImplementation(() => result) - const { rerender } = render() - const dialog = screen.getByRole('dialog') - const detailTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' }) - const tryTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' }) - expect(tryTab).toHaveAttribute('aria-disabled', 'true') - - result = { data: createMockAppDetail(), isLoading: false } - rerender() - - expect(screen.getByRole('dialog')).toBe(dialog) - expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toBe(detailTab) - expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' })).toBe(tryTab) - expect(tryTab).not.toHaveAttribute('aria-disabled', 'true') - }) - - it('keeps the tabs mounted when the initial load fails', () => { - let result = { data: null, isLoading: true, isError: false, refetch: vi.fn() } - mockDetailQueryResult.mockImplementation(() => result) - const { rerender } = render() - const detailTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' }) - - result = { ...result, isLoading: false, isError: true } - rerender() - - expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toBe(detailTab) - expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument() - }) - - it('starts a new dialog session with loading when a cached failure is retried', () => { - let result = { - data: null, - isLoading: true, - isFetching: true, - isFetched: true, - isFetchedAfterMount: false, - errorUpdateCount: 1, - refetch: vi.fn(), - } - mockDetailQueryResult.mockImplementation(() => result) - const { rerender } = render() - - expect(screen.getByRole('progressbar')).toBeInTheDocument() - expect( - screen.queryByRole('button', { name: 'common.operation.retry' }), - ).not.toBeInTheDocument() - - result = { ...result, isLoading: false, isFetching: false, isFetchedAfterMount: true } - rerender() - - expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument() - }) - - it('keeps the dialog visible while preview content suspends', async () => { - let resolvePreview: () => void = () => {} - mockPreviewSuspension.promise = new Promise((resolve) => { - resolvePreview = resolve - }) - render() - - const dialog = screen.getByRole('dialog') - const detailTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' }) - expect(screen.getByRole('progressbar')).toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' })).toBeInTheDocument() - - await act(async () => { - mockPreviewSuspension.promise = null - resolvePreview() - }) - - expect(screen.getByRole('dialog')).toBe(dialog) - expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toBe(detailTab) - expect(screen.getByTestId('preview-component')).toBeInTheDocument() - }) - - it('shows retry without the create panel when the app detail request fails', () => { - mockDetailQueryResult.mockReturnValue({ - data: null, - isError: true, - isFetching: false, - refetch: vi.fn(), - }) - - render() - - expect(screen.getByText('explore.tryApp.loadError')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeEnabled() - expect(screen.queryByTestId('app-info-component')).not.toBeInTheDocument() - expect(screen.queryByTestId('create-button')).not.toBeInTheDocument() - }) - - it('renders unknown unavailable state when app detail is empty', () => { - mockDetailQueryResult.mockReturnValue({ - data: null, - isLoading: false, - isError: false, - isFetching: false, - refetch: vi.fn(), - }) - - render() - - expect(screen.getByText('explore.tryApp.loadError')).toBeInTheDocument() - }) - - it('keeps the error block during retry and restores the preview after recovery', async () => { - const refetch = vi.fn().mockResolvedValue({ data: createMockAppDetail() }) - let result = { - data: null as TrialAppDetailResponse | null, - isLoading: false, - isFetched: true, - isFetchedAfterMount: true, - isError: true, - isFetching: false, - errorUpdateCount: 1, - refetch, - } - mockDetailQueryResult.mockImplementation(() => result) - const { rerender } = render() - - const retryButton = screen.getByRole('button', { name: 'common.operation.retry' }) - const errorBlock = screen.getByRole('alert') - retryButton.focus() - fireEvent.click(retryButton) - expect(refetch).toHaveBeenCalledTimes(1) - - result = { ...result, isLoading: true, isFetching: true } - rerender() - const retryingButton = screen.getByRole('button', { name: 'explore.tryApp.retrying' }) - expect(retryingButton).toBe(retryButton) - expect(screen.getByRole('alert')).toBe(errorBlock) - expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() - expect(retryingButton).toHaveAttribute('aria-disabled', 'true') - expect(retryingButton).toHaveFocus() - fireEvent.click(retryingButton) - expect(refetch).toHaveBeenCalledTimes(1) - - result = { - ...result, - data: createMockAppDetail(), - isLoading: false, - isError: false, - isFetching: false, - } - rerender() - expect(screen.getByTestId('preview-component')).toBeInTheDocument() - expect(screen.getByTestId('create-button')).toBeInTheDocument() - expect(errorBlock.parentElement).toHaveAttribute('aria-hidden', 'true') - await waitFor(() => { - expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toHaveFocus() - }) - }) - - it('allows another retry after a failed request', () => { - const refetch = vi.fn().mockResolvedValue({ data: null }) - let result = { - data: null, - isLoading: false, - isFetched: true, - isFetchedAfterMount: true, - isError: true, - isFetching: false, - refetch, - } - mockDetailQueryResult.mockImplementation(() => result) - const { rerender } = render() - - const retryButton = screen.getByRole('button', { name: 'common.operation.retry' }) - const errorBlock = screen.getByRole('alert') - fireEvent.click(retryButton) - result = { ...result, isLoading: true, isFetching: true } - rerender() - result = { ...result, isLoading: false, isFetching: false } - rerender() - - expect(screen.getByRole('alert')).toBe(errorBlock) - expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBe(retryButton) - fireEvent.click(retryButton) - expect(refetch).toHaveBeenCalledTimes(2) - }) + it('keeps the tabs and template name when the request fails and hides creation', async () => { + const request = createDeferred() + getDetail.mockReturnValue(request.promise) + const app = { + ...defaultApp, + app: { id: 'test-app-id', name: 'Sample template', icon_url: null }, + } + render() + const detailTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' }) + expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument() + await act(async () => request.reject(new Error('Unavailable'))) + expect(await screen.findByRole('button', { name: 'common.operation.retry' })).toBeEnabled() + expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toBe(detailTab) + expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument() + expect(screen.queryByTestId('app-info-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('create-button')).not.toBeInTheDocument() }) - describe('content rendering', () => { - it('defaults to details even when the app can be tried', async () => { - const app: RecommendedAppResponse = { app_id: 'test-app-id', can_trial: true } - - render() - - expect(await screen.findByTestId('preview-component')).toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toHaveAttribute( - 'aria-selected', - 'true', - ) - }) - - it('hides trial when the app is ineligible', async () => { - const app: RecommendedAppResponse = { app_id: 'test-app-id', can_trial: false } - - render() - - expect(await screen.findByTestId('preview-component')).toBeInTheDocument() - expect( - screen.queryByRole('tab', { name: 'explore.tryApp.tabHeader.try' }), - ).not.toBeInTheDocument() - expect(screen.queryByTestId('app-component')).not.toBeInTheDocument() - }) - - it('renders Tab component', async () => { - render() - - await waitFor(() => { - expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() - expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() - }) - }) - - it('renders Preview component by default (DETAIL mode)', async () => { - render() - - await waitFor(() => { - expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument() - expect(document.body.querySelector('[data-testid="app-component"]')).not.toBeInTheDocument() - }) - expect(screen.getByRole('dialog', { name: 'Test App Name' })).toBeInTheDocument() - }) - - it('names the dialog after the current template in all load states', () => { - const app: RecommendedAppResponse = { - ...defaultApp, - app: { id: 'test-app-id', name: 'Sample template', icon_url: null }, - } - const { rerender } = render() - expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument() - - mockDetailQueryResult.mockReturnValue({ data: null, isLoading: true }) - rerender() - expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument() - - mockDetailQueryResult.mockReturnValue({ data: null, isError: true, refetch: vi.fn() }) - rerender() - expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument() - }) - - it('renders AppInfo component', async () => { - render() - - await waitFor(() => { - expect( - document.body.querySelector('[data-testid="app-info-component"]'), - ).toBeInTheDocument() - }) - }) - - it('renders close button', async () => { - render() - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() - }) - }) + it('starts a reopened dialog with loading even after a cached failure', async () => { + const client = createConsoleQueryClient() + getDetail.mockRejectedValueOnce(new Error('Unavailable')) + const first = render(, client) + await screen.findByRole('button', { name: 'common.operation.retry' }) + first.unmount() + const request = createDeferred() + getDetail.mockReturnValue(request.promise) + render(, client) + expect(screen.getByRole('progressbar')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.retry' })).not.toBeInTheDocument() + await act(async () => request.reject(new Error('Still unavailable'))) + expect(await screen.findByRole('button', { name: 'common.operation.retry' })).toBeEnabled() }) - describe('tab switching', () => { - it('places Details before Try it in tab order', () => { - render() - - expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ - 'explore.tryApp.tabHeader.detail', - 'explore.tryApp.tabHeader.try', - ]) - }) - - it('switches to Try it and back to Details', async () => { - render() - - fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try')) - - await waitFor(() => { - expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) - - expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument() + it('keeps the dialog visible while preview content suspends', async () => { + const request = createDeferred() + mockPreviewSuspension.promise = request.promise + render() + const dialog = screen.getByRole('dialog') + await screen.findByTestId('app-info-component') + expect(screen.getByRole('progressbar')).toBeInTheDocument() + await act(async () => { + mockPreviewSuspension.promise = null + request.resolve() }) + expect(await screen.findByTestId('preview-component')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBe(dialog) }) - describe('close functionality', () => { - it('calls onClose when close button is clicked', async () => { - const mockOnClose = vi.fn() - - render() - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) - - expect(mockOnClose).toHaveBeenCalled() - }) - - it('calls onClose when the dialog requests close', async () => { - const mockOnClose = vi.fn() - - render() - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) - - expect(mockOnClose).toHaveBeenCalledTimes(1) - }) + it('keeps the error block during retry, prevents duplicate requests and restores preview and focus', async () => { + const user = userEvent.setup() + const request = createDeferred() + getDetail.mockRejectedValueOnce(new Error('Unavailable')).mockReturnValue(request.promise) + render() + const retryButton = await screen.findByRole('button', { name: 'common.operation.retry' }) + const errorBlock = screen.getByRole('alert') + await user.click(retryButton) + const retryingButton = await screen.findByRole('button', { name: 'explore.tryApp.retrying' }) + expect(retryingButton).toBe(retryButton) + expect(screen.getByRole('alert')).toBe(errorBlock) + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(retryingButton).toHaveAttribute('aria-disabled', 'true') + expect(retryingButton).toHaveFocus() + await user.click(retryingButton) + expect(getDetail).toHaveBeenCalledTimes(2) + await act(async () => request.resolve(createMockAppDetail())) + expect(await screen.findByTestId('preview-component')).toBeInTheDocument() + expect(screen.getByTestId('create-button')).toBeInTheDocument() + await waitFor(() => + expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toHaveFocus(), + ) }) - describe('create functionality', () => { - it('calls onCreate when create button in AppInfo is clicked', async () => { - const mockOnCreate = vi.fn() - - render() - - await waitFor(() => { - const createButton = document.body.querySelector('[data-testid="create-button"]') - expect(createButton).toBeInTheDocument() - - if (createButton) fireEvent.click(createButton) - }) - - expect(mockOnCreate).toHaveBeenCalledTimes(1) - }) + it('allows another retry after a failed retry', async () => { + const user = userEvent.setup() + getDetail + .mockRejectedValueOnce(new Error('Unavailable')) + .mockRejectedValueOnce(new Error('Still unavailable')) + render() + const retryButton = await screen.findByRole('button', { name: 'common.operation.retry' }) + await user.click(retryButton) + await waitFor(() => expect(getDetail).toHaveBeenCalledTimes(2)) + await waitFor(() => + expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeEnabled(), + ) + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(await screen.findByTestId('preview-component')).toBeInTheDocument() + expect(getDetail).toHaveBeenCalledTimes(3) }) - describe('catalog categories', () => { - it('uses the selected catalog app categories', async () => { - render( - , - ) - - await waitFor(() => { - const appInfo = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfo).toHaveAttribute('data-categories', 'AI Assistant,Workflow') - }) - }) - - it('uses an empty category list when the catalog does not provide categories', async () => { - render() - - await waitFor(() => { - const appInfo = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfo).toHaveAttribute('data-categories', '') - }) - }) + it('hides trial for ineligible apps', async () => { + render( + , + ) + await screen.findByTestId('preview-component') + expect( + screen.queryByRole('tab', { name: 'explore.tryApp.tabHeader.try' }), + ).not.toBeInTheDocument() }) - describe('hook calls', () => { - it('requests the canonical catalog app ID when nested metadata has another ID', () => { - render( - , - ) - - expect(mockDetailQueryResult).toHaveBeenCalledWith('my-specific-app-id') - }) + it('switches between details and trial after loading the canonical catalog app', async () => { + const user = userEvent.setup() + render( + , + ) + await screen.findByTestId('preview-component') + expect(getDetail).toHaveBeenCalledWith('my-specific-app-id') + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'explore.tryApp.tabHeader.detail', + 'explore.tryApp.tabHeader.try', + ]) + await user.click(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' })) + expect(await screen.findByTestId('app-component')).toHaveAttribute( + 'data-app-id', + 'my-specific-app-id', + ) + await user.click(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })) + expect(await screen.findByTestId('preview-component')).toHaveAttribute( + 'data-app-id', + 'my-specific-app-id', + ) }) - describe('props passing', () => { - it('passes appId to App component', async () => { - render( - , - ) - - fireEvent.click(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' })) - - await waitFor(() => { - const appComponent = document.body.querySelector('[data-testid="app-component"]') - expect(appComponent).toHaveAttribute('data-app-id', 'my-app-id') - }) - }) - - it('passes appId to Preview component when in Detail mode', async () => { - render( - , - ) - - await waitFor(() => { - expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) - - await waitFor(() => { - const previewComponent = document.body.querySelector('[data-testid="preview-component"]') - expect(previewComponent).toHaveAttribute('data-app-id', 'my-app-id') - }) - }) - - it('passes appId to AppInfo component', async () => { - render( - , - ) - - await waitFor(() => { - const appInfoComponent = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfoComponent).toHaveAttribute('data-app-id', 'my-app-id') - }) - }) - - it('passes appDetail to AppInfo component', async () => { - render() + it('calls onClose from the close button', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + expect(onClose).toHaveBeenCalledOnce() + }) - await waitFor(() => { - const appInfoComponent = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfoComponent?.textContent).toContain('Test App Name') - }) - }) + it('calls onClose when the dialog receives Escape', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalledOnce() }) - describe('TypeEnum export', () => { - it('exports TypeEnum correctly', () => { - expect(TypeEnum.TRY).toBe('try') - expect(TypeEnum.DETAIL).toBe('detail') - }) + it('creates from the loaded template and passes catalog categories to app info', async () => { + const user = userEvent.setup() + const onCreate = vi.fn() + render( + , + ) + expect(await screen.findByTestId('app-info-component')).toHaveAttribute( + 'data-categories', + 'AI Assistant,Workflow', + ) + await user.click(screen.getByRole('button', { name: 'Create' })) + expect(onCreate).toHaveBeenCalledOnce() }) }) diff --git a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx index cd7e59e84de411..8cec590a8c4317 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx @@ -1,14 +1,22 @@ import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, screen } from '@testing-library/react' import * as React from 'react' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { BlockEnum } from '@/app/components/workflow/types' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import AppInfo from '../index' -const mockUseGetRequirements = vi.fn() - -vi.mock('../use-get-requirements', () => ({ - default: (...args: unknown[]) => mockUseGetRequirements(...args), -})) +const { getWorkflow } = vi.hoisted(() => ({ getWorkflow: vi.fn() })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'trialApps.byAppId.workflows.get': getWorkflow, + }), + } +}) const createMockAppDetail = ( mode: string, @@ -47,9 +55,7 @@ const createMockAppDetail = ( describe('AppInfo', () => { beforeEach(() => { - mockUseGetRequirements.mockReturnValue({ - requirements: [], - }) + getWorkflow.mockResolvedValue({ graph: { nodes: [] } }) }) afterEach(() => { @@ -205,87 +211,66 @@ describe('AppInfo', () => { }) describe('requirements', () => { - it('renders requirements when available', () => { - mockUseGetRequirements.mockReturnValue({ - requirements: [ - { name: 'OpenAI GPT-4', iconUrl: 'https://example.com/icon1.png' }, - { name: 'Google Search', iconUrl: 'https://example.com/icon2.png' }, - ], + it('derives and deduplicates dependencies from the requested workflow', async () => { + getWorkflow.mockResolvedValue({ + graph: { + nodes: [ + { + data: { + type: BlockEnum.LLM, + model: { name: 'GPT-4', provider: 'langgenius/openai/openai' }, + }, + }, + { + data: { + type: BlockEnum.LLM, + model: { name: 'GPT-4', provider: 'langgenius/openai/openai' }, + }, + }, + { + data: { + type: BlockEnum.Tool, + provider_id: 'langgenius/google/google', + tool_label: 'Google Search', + }, + }, + ], + }, }) - - const appDetail = createMockAppDetail('chat') - const mockOnCreate = vi.fn() - - render() - - expect(screen.getByText('explore.tryApp.requirements')).toBeInTheDocument() - expect(screen.getByText('OpenAI GPT-4')).toBeInTheDocument() + render( + , + ) + expect(await screen.findByText('GPT-4')).toBeInTheDocument() + expect(screen.getAllByText('GPT-4')).toHaveLength(1) expect(screen.getByText('Google Search')).toBeInTheDocument() + expect(getWorkflow).toHaveBeenCalledWith({ params: { app_id: 'workflow-id' } }) }) - it('does not render requirements section when empty', () => { - mockUseGetRequirements.mockReturnValue({ - requirements: [], - }) - - const appDetail = createMockAppDetail('chat') - const mockOnCreate = vi.fn() - - render() - + it('omits the requirements section when no dependencies are configured', () => { + render( + , + ) expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument() }) - it('renders requirement icons with correct image src', () => { - mockUseGetRequirements.mockReturnValue({ - requirements: [{ name: 'Test Tool', iconUrl: 'https://example.com/test-icon.png' }], - }) - - const appDetail = createMockAppDetail('chat') - const mockOnCreate = vi.fn() - + it('keeps the requirement visible when its real provider icon fails to load', () => { const { container } = render( - , + , ) - - const iconElement = container.querySelector('img[src="https://example.com/test-icon.png"]') - expect(iconElement).toBeInTheDocument() - }) - - it('falls back to default icon when requirement image fails to load', () => { - mockUseGetRequirements.mockReturnValue({ - requirements: [{ name: 'Broken Tool', iconUrl: 'https://example.com/broken-icon.png' }], - }) - - const appDetail = createMockAppDetail('chat') - const mockOnCreate = vi.fn() - - render() - - const requirementRow = screen.getByText('Broken Tool').parentElement as HTMLElement - const iconImage = requirementRow.querySelector('img') as HTMLImageElement - expect(iconImage).toBeInTheDocument() - - fireEvent.error(iconImage) - - expect(requirementRow.querySelector('img')).not.toBeInTheDocument() - expect( - requirementRow.querySelector('.i-custom-public-other-default-tool-icon'), - ).toBeInTheDocument() - }) - }) - - describe('hook calls', () => { - it('calls useGetRequirements with correct parameters', () => { - const appDetail = createMockAppDetail('chat') - const mockOnCreate = vi.fn() - - render() - - expect(mockUseGetRequirements).toHaveBeenCalledWith({ - appDetail, - appId: 'my-app-id', - }) + expect(screen.getByText('openai')).toBeInTheDocument() + const icon = container.querySelector('img[src$="/plugins/langgenius/openai/icon"]')! + expect(icon).toBeInTheDocument() + fireEvent.error(icon) + expect(icon).not.toBeInTheDocument() + expect(screen.getByText('openai')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts index d911605db61c12..330dd1de5c54dd 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts +++ b/web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts @@ -1,14 +1,39 @@ -import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen' -import { renderHook } from '@testing-library/react' +import type { + TrialAppDetailResponse, + TrialWorkflowResponse, +} from '@dify/contracts/api/console/trial-apps/types.gen' +import { waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data' import useGetRequirements from '../use-get-requirements' -const mockUseQuery = vi.fn() - -vi.mock('@tanstack/react-query', async (importOriginal) => ({ - ...(await importOriginal()), - useQuery: (...args: unknown[]) => mockUseQuery(...args), -})) +const getWorkflow = vi.hoisted(() => vi.fn(() => new Promise(() => {}))) +let workflowData: TrialWorkflowResponse | undefined +const setWorkflowResponse = (response: { data: Pick | null }) => { + workflowData = response.data ? { id: 'workflow-1', ...response.data } : undefined +} +function renderHook(callback: () => T) { + const queryClient = createConsoleQueryClient() + if (workflowData) + queryClient.setQueryData( + consoleQuery.trialApps.byAppId.workflows.get.queryKey({ + input: { params: { app_id: 'test-app-id' } }, + }), + workflowData, + ) + return renderHookWithConsoleQuery(callback, { queryClient }) +} +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'trialApps.byAppId.workflows.get': getWorkflow, + }), + } +}) vi.mock('@/config', () => ({ MARKETPLACE_API_PREFIX: 'https://marketplace.api', @@ -56,7 +81,7 @@ describe('useGetRequirements', () => { describe('basic app modes (chat, completion, agent-chat)', () => { it('returns model provider for chat mode', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('chat') const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) @@ -69,7 +94,7 @@ describe('useGetRequirements', () => { }) it('returns model provider for completion mode', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('completion', { model_config: { @@ -91,7 +116,7 @@ describe('useGetRequirements', () => { }) it('returns model provider and tools for agent-chat mode', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('agent-chat', { model_config: { @@ -134,7 +159,7 @@ describe('useGetRequirements', () => { }) it('filters out disabled tools in agent mode', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('agent-chat', { model_config: { @@ -171,7 +196,7 @@ describe('useGetRequirements', () => { describe('advanced app modes (workflow, advanced-chat)', () => { it('returns requirements from flow data for workflow mode', () => { - mockUseQuery.mockReturnValue({ + setWorkflowResponse({ data: { graph: { nodes: [ @@ -205,7 +230,7 @@ describe('useGetRequirements', () => { }) it('returns requirements from flow data for advanced-chat mode', () => { - mockUseQuery.mockReturnValue({ + setWorkflowResponse({ data: { graph: { nodes: [ @@ -231,7 +256,7 @@ describe('useGetRequirements', () => { }) it('returns empty requirements when flow data has no nodes', () => { - mockUseQuery.mockReturnValue({ + setWorkflowResponse({ data: { graph: { nodes: [], @@ -245,8 +270,8 @@ describe('useGetRequirements', () => { expect(result.current.requirements).toHaveLength(0) }) - it('returns empty requirements when flow data is null', () => { - mockUseQuery.mockReturnValue({ + it('returns empty requirements while flow data is pending', () => { + setWorkflowResponse({ data: null, }) @@ -257,7 +282,7 @@ describe('useGetRequirements', () => { }) it('extracts multiple LLM nodes from flow data', () => { - mockUseQuery.mockReturnValue({ + setWorkflowResponse({ data: { graph: { nodes: [ @@ -293,7 +318,7 @@ describe('useGetRequirements', () => { }) it('extracts multiple tool nodes from flow data', () => { - mockUseQuery.mockReturnValue({ + setWorkflowResponse({ data: { graph: { nodes: [ @@ -327,7 +352,7 @@ describe('useGetRequirements', () => { describe('deduplication', () => { it('removes duplicate requirements by name', () => { - mockUseQuery.mockReturnValue({ + setWorkflowResponse({ data: { graph: { nodes: [ @@ -364,7 +389,7 @@ describe('useGetRequirements', () => { describe('icon URL generation', () => { it('generates correct icon URL for model providers', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('chat', { model_config: { @@ -387,7 +412,7 @@ describe('useGetRequirements', () => { }) it('maps google model provider to gemini plugin icon URL', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('chat', { model_config: { @@ -410,7 +435,7 @@ describe('useGetRequirements', () => { }) it('maps special builtin tool providers to *_tool plugin icon URL', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('agent-chat', { model_config: { @@ -446,21 +471,23 @@ describe('useGetRequirements', () => { describe('hook calls', () => { it('does not request workflow data for basic apps', () => { - mockUseQuery.mockReturnValue({ data: null }) + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('chat') renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) - expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })) + expect(getWorkflow).not.toHaveBeenCalled() }) - it('requests workflow data for advanced apps', () => { - mockUseQuery.mockReturnValue({ data: null }) + it('requests workflow data for advanced apps', async () => { + setWorkflowResponse({ data: null }) const appDetail = createMockAppDetail('workflow') renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' })) - expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ enabled: true })) + await waitFor(() => + expect(getWorkflow).toHaveBeenCalledWith({ params: { app_id: 'test-app-id' } }), + ) }) }) }) diff --git a/web/app/components/goto-anything/__tests__/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx index 0bdeabe7d2de15..2f852089940371 100644 --- a/web/app/components/goto-anything/__tests__/index.spec.tsx +++ b/web/app/components/goto-anything/__tests__/index.spec.tsx @@ -3,10 +3,11 @@ import type { SlashCommand } from '../actions/commands/types' import type { ActionItem, SearchQueryOptions, SearchResult } from '../actions/types' import { DialogTrigger } from '@langgenius/dify-ui/dialog' import { detectPlatform } from '@tanstack/react-hotkeys' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { toast } from '@/app/notifications' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { goCommand } from '../actions/commands/go' import { gotoAnythingDialogHandle } from '../dialog-handle' @@ -26,11 +27,6 @@ vi.mock('@/next/navigation', () => ({ useParams: () => ({}), })) -let debouncedSearchQuery: string | undefined -vi.mock('foxact/use-debounced-value', () => ({ - useDebouncedValue: (value: T) => (debouncedSearchQuery ?? value) as T, -})) - const isMac = detectPlatform() === 'mac' function triggerSearchShortcut(target: Document | HTMLElement = document) { @@ -41,34 +37,44 @@ function triggerSearchShortcut(target: Document | HTMLElement = document) { }) } -type RemoteQueryState = { +type RemoteResponse = { data: TestSearchResult[] - isLoading: boolean - isFetching?: boolean - isError: boolean + pending?: boolean error: Error | null } -const emptyRemoteQueryState = (): RemoteQueryState => ({ +const emptyRemoteResponse = (): RemoteResponse => ({ data: [], - isLoading: false, - isError: false, + error: null, }) -let remoteQueryStates: Record< - 'app' | 'knowledge' | 'plugin' | 'skill' | 'agent', - RemoteQueryState -> = { - app: emptyRemoteQueryState(), - knowledge: emptyRemoteQueryState(), - plugin: emptyRemoteQueryState(), - skill: emptyRemoteQueryState(), - agent: emptyRemoteQueryState(), +let remoteResponses: Record<'app' | 'knowledge' | 'plugin' | 'skill' | 'agent', RemoteResponse> = { + app: emptyRemoteResponse(), + knowledge: emptyRemoteResponse(), + plugin: emptyRemoteResponse(), + skill: emptyRemoteResponse(), + agent: emptyRemoteResponse(), +} +let requestedProviders: string[] = [] +let remoteRequests: Array<[keyof typeof remoteResponses, string]> = [] + +const pendingRemoteRequests: Array<{ + provider: keyof typeof remoteResponses + resolve: (value: TestSearchResult[]) => void +}> = [] + +async function searchRemote(provider: keyof typeof remoteResponses, searchTerm: string) { + requestedProviders.push(provider) + remoteRequests.push([provider, searchTerm]) + const response = remoteResponses[provider] + if (response.error) throw response.error + if (response.pending) + return new Promise((resolve) => + pendingRemoteRequests.push({ provider, resolve }), + ) + return [...response.data] } -let enabledRemoteQueryKeys: string[] = [] -let enabledRemoteSearches: Array<[keyof typeof remoteQueryStates, string]> = [] -let previousRemoteData: Partial> = {} function setRemoteResults(results: TestSearchResult[]) { results.forEach((result) => { @@ -79,42 +85,14 @@ function setRemoteResults(results: TestSearchResult[]) { result.type === 'skill' || result.type === 'agent' ) - remoteQueryStates[result.type].data.push(result) + remoteResponses[result.type].data.push(result) }) } -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - keepPreviousData: (previousData: unknown) => previousData, - useQuery: (options: { - queryKey: readonly unknown[] - enabled?: boolean - placeholderData?: (previousData: unknown) => unknown - }) => { - if (typeof options.queryKey[0] !== 'string') - return actual.useQuery({ ...options, placeholderData: undefined }) - const provider = options.queryKey[0] as keyof typeof remoteQueryStates - if (!options.enabled) return emptyRemoteQueryState() - - enabledRemoteQueryKeys.push(provider) - enabledRemoteSearches.push([provider, options.queryKey[1] as string]) - const state = remoteQueryStates[provider] - let data = state.data - if (state.isFetching && data.length === 0 && options.placeholderData) - data = (options.placeholderData(previousRemoteData[provider]) as TestSearchResult[]) ?? [] - if (!state.isLoading && !state.isFetching && !state.isError) - previousRemoteData[provider] = state.data - - return { ...state, data } - }, - } -}) - vi.mock('../actions/app', () => ({ appSearchQueryOptions: (searchTerm: string, _scoped: boolean, options: SearchQueryOptions) => ({ queryKey: ['app', searchTerm], + queryFn: () => searchRemote('app', searchTerm), ...options, }), })) @@ -122,6 +100,7 @@ vi.mock('../actions/app', () => ({ vi.mock('../actions/knowledge', () => ({ knowledgeSearchQueryOptions: (searchTerm: string, options: SearchQueryOptions) => ({ queryKey: ['knowledge', searchTerm], + queryFn: () => searchRemote('knowledge', searchTerm), ...options, }), })) @@ -129,6 +108,7 @@ vi.mock('../actions/knowledge', () => ({ vi.mock('../actions/plugin', () => ({ pluginSearchQueryOptions: (searchTerm: string, _locale: string, options: SearchQueryOptions) => ({ queryKey: ['plugin', searchTerm], + queryFn: () => searchRemote('plugin', searchTerm), ...options, }), })) @@ -136,6 +116,7 @@ vi.mock('../actions/plugin', () => ({ vi.mock('../actions/skill', () => ({ skillSearchQueryOptions: (searchTerm: string, options: SearchQueryOptions) => ({ queryKey: ['skill', searchTerm], + queryFn: () => searchRemote('skill', searchTerm), ...options, }), })) @@ -143,6 +124,7 @@ vi.mock('../actions/skill', () => ({ vi.mock('../actions/agent', () => ({ agentSearchQueryOptions: (searchTerm: string, options: SearchQueryOptions) => ({ queryKey: ['agent', searchTerm], + queryFn: () => searchRemote('agent', searchTerm), ...options, }), })) @@ -153,14 +135,6 @@ const visibilityState = vi.hoisted(() => ({ enableSkill: true, })) -vi.mock('jotai', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useAtomValue: () => visibilityState.datasetOperator, - } -}) - vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => visibilityState.agentEnabled, })) @@ -267,27 +241,38 @@ vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({ })) const renderGotoAnything = (ui: React.ReactElement) => { - const { wrapper } = createConsoleQueryWrapper({ + const { wrapper, queryClient } = createConsoleQueryWrapper({ + currentWorkspace: { role: visibilityState.datasetOperator ? 'dataset_operator' : 'owner' }, features: { enable_skill: visibilityState.enableSkill }, }) - return render(ui, { wrapper }) + const view = render(ui, { wrapper }) + return { + ...view, + rerender: (nextUi: React.ReactElement) => { + act(() => + seedCurrentWorkspaceQuery(queryClient, { + role: visibilityState.datasetOperator ? 'dataset_operator' : 'owner', + }), + ) + view.rerender(nextUi) + }, + } } describe('GotoAnything', () => { beforeEach(() => { routerPush.mockClear() gotoAnythingDialogHandle.close() - remoteQueryStates = { - app: emptyRemoteQueryState(), - knowledge: emptyRemoteQueryState(), - plugin: emptyRemoteQueryState(), - skill: emptyRemoteQueryState(), - agent: emptyRemoteQueryState(), + remoteResponses = { + app: emptyRemoteResponse(), + knowledge: emptyRemoteResponse(), + plugin: emptyRemoteResponse(), + skill: emptyRemoteResponse(), + agent: emptyRemoteResponse(), } - debouncedSearchQuery = undefined - enabledRemoteQueryKeys = [] - enabledRemoteSearches = [] - previousRemoteData = {} + requestedProviders = [] + remoteRequests = [] + pendingRemoteRequests.length = 0 matchActionMock.mockReset() createActionsMock.mockImplementation(() => actionsMock) visibilityState.agentEnabled = true @@ -534,7 +519,7 @@ describe('GotoAnything', () => { expect(screen.queryByRole('gridcell', { name: /\/refine/ })).not.toBeInTheDocument() expect(screen.getByRole('gridcell', { name: /@app/ })).toBeInTheDocument() expect(input).toHaveAccessibleDescription('app.gotoAnything.searchHint') - expect(enabledRemoteQueryKeys).toEqual([]) + expect(requestedProviders).toEqual([]) }) it('opens a command submenu and returns home by clearing the input', async () => { @@ -569,8 +554,7 @@ describe('GotoAnything', () => { mockAvailableCommands = [ { name: 'models', description: 'Models', mode: 'direct', execute, search: () => [] }, ] - Object.values(remoteQueryStates).forEach((query) => { - query.isError = true + Object.values(remoteResponses).forEach((query) => { query.error = new Error('Offline') }) renderGotoAnything() @@ -617,17 +601,21 @@ describe('GotoAnything', () => { mockAvailableCommands = [ { name: 'models', description: 'Models', mode: 'direct', search: () => [], execute }, ] - remoteQueryStates.app.isLoading = true - const { rerender } = renderGotoAnything() + remoteResponses.app.pending = true + renderGotoAnything() triggerSearchShortcut() const input = await screen.findByRole('combobox') await user.type(input, 'models') + await waitFor(() => expect(remoteRequests).toContainEqual(['app', 'models'])) await user.keyboard('{ArrowDown}') const selected = input.getAttribute('aria-activedescendant') - remoteQueryStates.app.isLoading = false + remoteResponses.app.pending = false setRemoteResults([{ id: 'app', title: 'Models app', type: 'app', path: '/apps/model' }]) - rerender() - expect(screen.getByRole('option', { name: /Models app/ })).toBeInTheDocument() + await act(async () => { + for (const request of pendingRemoteRequests) + request.resolve([...remoteResponses[request.provider].data]) + }) + expect(await screen.findByRole('option', { name: /Models app/ })).toBeInTheDocument() expect(input).toHaveAttribute('aria-activedescendant', selected) expect(input).toHaveFocus() await user.keyboard('{Enter}') @@ -680,7 +668,9 @@ describe('GotoAnything', () => { visibilityState.datasetOperator = true rerender() expect(screen.queryByRole('option', { name: 'Agents /agents' })).not.toBeInTheDocument() - expect(screen.queryByRole('option', { name: 'Skills /skills' })).not.toBeInTheDocument() + await waitFor(() => + expect(screen.queryByRole('option', { name: 'Skills /skills' })).not.toBeInTheDocument(), + ) await user.click(screen.getByRole('option', { name: 'Apps /apps' })) expect(routerPush).toHaveBeenCalledExactlyOnceWith('/apps') await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) @@ -758,6 +748,7 @@ describe('GotoAnything', () => { }) await user.type(input, 'keyboard') + await screen.findByRole('option', { name: /Keyboard App/ }) await user.keyboard('{ArrowDown}{Enter}') expect(routerPush).toHaveBeenCalledWith('/apps/keyboard') @@ -896,7 +887,7 @@ describe('GotoAnything', () => { await user.keyboard('{Enter}') expect(input).toHaveValue('/theme ') - expect(screen.getByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() + expect(await screen.findByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() }) it('keeps a submenu root result visible while the committed delimiter catches up', async () => { @@ -926,13 +917,13 @@ describe('GotoAnything', () => { name: 'app.gotoAnything.searchTitle', }) await user.type(input, '/theme') + await screen.findByRole('gridcell', { name: /\/theme/ }) - debouncedSearchQuery = '/theme' await user.keyboard('{Enter}') await user.type(input, 'unknown') expect(input).toHaveValue('/theme unknown') - expect(screen.getByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() + expect(await screen.findByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searching') expect(screen.queryByText('app.gotoAnything.noResults')).not.toBeInTheDocument() }) @@ -971,10 +962,9 @@ describe('GotoAnything', () => { await user.type(input, 'search') expect(await screen.findByText('Stale Remote App')).toBeInTheDocument() - debouncedSearchQuery = 'search' - remoteQueryStates.app = { - ...emptyRemoteQueryState(), - isFetching: true, + remoteResponses.app = { + ...emptyRemoteResponse(), + pending: true, } await user.clear(input) await user.type(input, '/') @@ -985,7 +975,7 @@ describe('GotoAnything', () => { await user.keyboard('{Enter}') expect(input).toHaveValue('/theme ') - expect(screen.getByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() + expect(await screen.findByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() expect(screen.queryByText('Stale Remote App')).not.toBeInTheDocument() }) @@ -1011,18 +1001,16 @@ describe('GotoAnything', () => { name: 'app.gotoAnything.searchTitle', }) await user.type(input, '/theme ') - expect(screen.getByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() + expect(await screen.findByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() - debouncedSearchQuery = '/theme ' actionsMock.slash.search.mockClear() await user.type(input, 'unknown') expect(actionsMock.slash.search).not.toHaveBeenCalled() - expect(screen.getByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() + expect(await screen.findByRole('option', { name: /Dark Theme/ })).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searching') expect(screen.queryByText('app.gotoAnything.noResults')).not.toBeInTheDocument() - debouncedSearchQuery = '/theme unknownx' await user.type(input, 'x') expect(await screen.findByText('app.gotoAnything.noResults')).toBeInTheDocument() @@ -1039,8 +1027,10 @@ describe('GotoAnything', () => { await user.type(input, 'research') - expect(enabledRemoteQueryKeys).toEqual( - expect.arrayContaining(['app', 'knowledge', 'plugin', 'skill', 'agent']), + await waitFor(() => + expect(requestedProviders).toEqual( + expect.arrayContaining(['app', 'knowledge', 'plugin', 'skill', 'agent']), + ), ) }) @@ -1056,10 +1046,12 @@ describe('GotoAnything', () => { await user.type(input, 'research') - expect(enabledRemoteQueryKeys).toEqual( - expect.arrayContaining(['app', 'knowledge', 'plugin', 'agent']), + await waitFor(() => + expect(requestedProviders).toEqual( + expect.arrayContaining(['app', 'knowledge', 'plugin', 'agent']), + ), ) - expect(enabledRemoteQueryKeys).not.toContain('skill') + expect(requestedProviders).not.toContain('skill') }) it('trims trailing whitespace before an ordinary remote search', async () => { @@ -1072,14 +1064,16 @@ describe('GotoAnything', () => { await user.type(input, 'research ') - expect(enabledRemoteSearches).toEqual( - expect.arrayContaining([ - ['app', 'research'], - ['knowledge', 'research'], - ['plugin', 'research'], - ]), + await waitFor(() => + expect(remoteRequests).toEqual( + expect.arrayContaining([ + ['app', 'research'], + ['knowledge', 'research'], + ['plugin', 'research'], + ]), + ), ) - expect(enabledRemoteSearches).not.toEqual( + expect(remoteRequests).not.toEqual( expect.arrayContaining([ ['app', 'research '], ['knowledge', 'research '], @@ -1117,7 +1111,7 @@ describe('GotoAnything', () => { await user.type(input, 'node') expect(await screen.findByText('Stable Node')).toBeInTheDocument() - debouncedSearchQuery = 'node' + await waitFor(() => expect(remoteRequests).toContainEqual(['app', 'node'])) nodeSearch.mockClear() await user.type(input, 'xyz') @@ -1125,7 +1119,6 @@ describe('GotoAnything', () => { expect(screen.getByText('Stable Node')).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searching') - debouncedSearchQuery = 'nodexyz2' await user.type(input, '2') expect(await screen.findByText('app.gotoAnything.noResults')).toBeInTheDocument() @@ -1149,8 +1142,8 @@ describe('GotoAnything', () => { await user.type(input, `${scope} research`) - expect(enabledRemoteQueryKeys).toContain(provider) - expect(enabledRemoteQueryKeys).not.toEqual( + await waitFor(() => expect(requestedProviders).toContain(provider)) + expect(requestedProviders).not.toEqual( expect.arrayContaining( ['app', 'knowledge', 'plugin', 'skill', 'agent'].filter((key) => key !== provider), ), @@ -1197,19 +1190,19 @@ describe('GotoAnything', () => { }) await user.type(input, '@') - debouncedSearchQuery = '@' - enabledRemoteQueryKeys = [] + await screen.findByText('@app') + requestedProviders = [] await user.click(screen.getByText('@app')) expect(input).toHaveValue('@app ') - expect(enabledRemoteQueryKeys).toEqual([]) + expect(requestedProviders).toEqual([]) }) }) describe('empty states', () => { it('should show loading state', async () => { const user = userEvent.setup() - remoteQueryStates.app.isLoading = true + remoteResponses.app.pending = true renderGotoAnything() triggerSearchShortcut() @@ -1234,12 +1227,12 @@ describe('GotoAnything', () => { it('should show error state', async () => { const user = userEvent.setup() const testError = new Error('Search failed') - remoteQueryStates = { - app: { data: [], isLoading: false, isError: true, error: testError }, - knowledge: { data: [], isLoading: false, isError: true, error: testError }, - plugin: { data: [], isLoading: false, isError: true, error: testError }, - skill: { data: [], isLoading: false, isError: true, error: testError }, - agent: { data: [], isLoading: false, isError: true, error: testError }, + remoteResponses = { + app: { data: [], error: testError }, + knowledge: { data: [], error: testError }, + plugin: { data: [], error: testError }, + skill: { data: [], error: testError }, + agent: { data: [], error: testError }, } renderGotoAnything() @@ -1254,7 +1247,9 @@ describe('GotoAnything', () => { const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') await user.type(input, 'search') - expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searchFailed') + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searchFailed'), + ) expect(screen.getAllByText('app.gotoAnything.searchFailed')).toHaveLength(2) expect(screen.queryByText('app.gotoAnything.someServicesUnavailable')).not.toBeInTheDocument() }) @@ -1270,10 +1265,9 @@ describe('GotoAnything', () => { data: {}, }, ]) - remoteQueryStates.plugin = { + remoteResponses.plugin = { data: [], - isLoading: false, - isError: true, + error: new Error('Marketplace unavailable'), } @@ -1360,10 +1354,9 @@ describe('GotoAnything', () => { await user.type(input, 'app') expect(await screen.findByText('Stable App')).toBeInTheDocument() - debouncedSearchQuery = 'app' - remoteQueryStates.app = { - ...emptyRemoteQueryState(), - isFetching: true, + remoteResponses.app = { + ...emptyRemoteResponse(), + pending: true, } await user.type(input, 'x') @@ -1371,14 +1364,13 @@ describe('GotoAnything', () => { expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searching') expect(screen.queryByText('app.gotoAnything.noResults')).not.toBeInTheDocument() - remoteQueryStates = { - app: emptyRemoteQueryState(), - knowledge: emptyRemoteQueryState(), - plugin: emptyRemoteQueryState(), - skill: emptyRemoteQueryState(), - agent: emptyRemoteQueryState(), + remoteResponses = { + app: emptyRemoteResponse(), + knowledge: emptyRemoteResponse(), + plugin: emptyRemoteResponse(), + skill: emptyRemoteResponse(), + agent: emptyRemoteResponse(), } - debouncedSearchQuery = 'appx2' await user.type(input, '2') expect(await screen.findByText('app.gotoAnything.noResults')).toBeInTheDocument() diff --git a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts index faf634ec905c38..5274065506eeac 100644 --- a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts +++ b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts @@ -6,17 +6,7 @@ vi.mock('@/service/marketplace', () => ({ marketplaceQuery: { searchAdvanced: { queryOptions: serviceMocks.queryOptions } }, })) -vi.mock('@/i18n/metadata', async (importOriginal) => ({ - ...(await importOriginal()), - renderI18nObject: vi.fn((value: Record | string, locale: string) => - typeof value === 'string' ? value : value[locale] || value.en_US || '', - ), -})) - vi.mock('../../../plugins/card/base/card-icon', () => ({ default: () => null })) -vi.mock('../../../plugins/marketplace/utils', () => ({ - getFormattedPlugin: vi.fn((plugin) => ({ ...plugin, icon: 'icon-url' })), -})) describe('plugin search query', () => { beforeEach(() => vi.clearAllMocks()) @@ -47,6 +37,8 @@ describe('plugin search query', () => { plugins: [ { name: 'plugin-1', + org: 'dify', + type: 'plugin', label: { en_US: 'My Plugin' }, brief: { en_US: 'A plugin' }, icon: 'icon.png', diff --git a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx index 8d2afb150be9b2..d6c2b5b6c66881 100644 --- a/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/compliance.spec.tsx @@ -33,16 +33,6 @@ vi.mock('@/utils/download', () => ({ downloadUrl: vi.fn(), })) -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: (...args: Parameters) => - args[0] === 'pricing' ? actual.useQueryState(...args) : [null, mockSetSettingsDestination], - } -}) - function render(...args: Parameters) { args[0] = {args[0]} return renderWithoutPricing(...args) @@ -216,7 +206,7 @@ describe('Compliance', () => { ) }) - it('should handle upgrade click on badge for non-sandbox plan', () => { + it('should handle upgrade click on badge for non-sandbox plan', async () => { // Arrange seedFeatures(queryClient, { billing: { subscription: { plan: 'professional' } } }) @@ -227,7 +217,9 @@ describe('Compliance', () => { fireEvent.click(upgradeBadges[0]!) // Assert - expect(mockSetSettingsDestination).toHaveBeenCalledWith('billing') + await waitFor(() => + expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('billing'), + ) }) // isPending branches: spinner visible and the owning menu item blocks a second call diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index f74c3068f76856..45fb6977ff8553 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderToString } from 'react-dom/server' import AccountAvatar from '@/app/account/(commonLayout)/avatar' @@ -6,26 +6,15 @@ import AccountSection from '@/app/components/main-nav/components/account-section import EducationUserInfo from '@/app/education/user-info' import { useLogout } from '@/service/use-common' import { createAccountProfileQueryClient } from '@/test/console/account-profile' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { renderWithConsoleQuery as renderWithConsoleQueryWithoutUrlState } from '@/test/console/query-data' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import AccountDropdown from '../index' -const { mockSetSettingsDestination } = vi.hoisted(() => ({ - mockSetSettingsDestination: vi.fn(), -})) - vi.mock('@/service/use-common', async (importOriginal) => ({ ...(await importOriginal()), useLogout: vi.fn(), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: () => [null, mockSetSettingsDestination], - } -}) - vi.mock('next-themes', () => ({ useTheme: () => ({ theme: 'system', setTheme: vi.fn() }), })) @@ -38,6 +27,12 @@ const userProfile = { } const accountMenuAccessibleName = `${userProfile.name} accountSettings.account.account` +const onUrlUpdate = vi.fn() +const renderWithConsoleQuery = createNuqsTestRenderer( + renderWithConsoleQueryWithoutUrlState, + () => ({ onUrlUpdate }), +) + const renderAccountDropdown = () => { const queryClient = createAccountProfileQueryClient(userProfile) @@ -142,7 +137,9 @@ describe('AccountDropdown', () => { await user.click(screen.getByRole('button', { name: 'accountSettings.account.account' })) await user.click(await screen.findByText('navigation.settings.preferences')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('preferences'), + ) }) it.each(['main navigation', 'account page', 'education'] as const)( diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index f4f8e222f58995..2b14aa0c03abbf 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -79,13 +79,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ ), })) -vi.mock('@/service/use-common', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - } -}) - vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx index ad58fd96260ad1..4af46d19d87827 100644 --- a/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx @@ -1,7 +1,7 @@ import type { SettingsDestination } from '@/app/components/header/account-setting/query-params' import { Dialog } from '@langgenius/dify-ui/dialog' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { @@ -10,17 +10,10 @@ import { } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { UpdateSettingDialogForm } from '../update-setting-dialog-form' -const mockSetSettingsDestination = vi.fn() let mockSettingsDestination: SettingsDestination | null = null -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: () => [mockSettingsDestination, mockSetSettingsDestination], - } -}) vi.mock('react-i18next', async () => { const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock') @@ -59,6 +52,12 @@ vi.mock( }), ) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ + onUrlUpdate, + searchParams: mockSettingsDestination ? { settings: mockSettingsDestination } : '', +})) + function renderForm(ui: React.ReactElement) { const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }) client.setQueryData(userProfileQueryOptions().queryKey, { @@ -113,10 +112,12 @@ describe('UpdateSettingDialogForm', () => { await user.click(screen.getByText('autoUpdate.changeTimezone')) expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false, expect.anything()) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('preferences'), + ) }) - it('should replace the current destination when timezone link is clicked inside settings', () => { + it('should replace the current destination when timezone link is clicked inside settings', async () => { mockSettingsDestination = 'provider' renderForm( @@ -138,9 +139,13 @@ describe('UpdateSettingDialogForm', () => { fireEvent.click(screen.getByText('autoUpdate.changeTimezone')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences', { - history: 'replace', - shallow: true, - }) + await waitFor(() => + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?settings=preferences', + options: expect.objectContaining({ history: 'replace', shallow: true }), + }), + ), + ) }) }) diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx index f9e7a3b21072c2..855ea9bc972f83 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx @@ -5,13 +5,13 @@ import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { ApiBasedExtensionPage } from '../index' const { - mockApiBasedExtensionsQuery, + listExtensions, mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockDeleteApiBasedExtension, mockWorkspacePermissionKeys, } = vi.hoisted(() => ({ - mockApiBasedExtensionsQuery: vi.fn(), + listExtensions: vi.fn(), mockCreateApiBasedExtension: vi.fn(), mockUpdateApiBasedExtension: vi.fn(), mockDeleteApiBasedExtension: vi.fn(), @@ -27,47 +27,17 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('@/service/console', () => ({ - consoleQuery: { - systemFeatures: { - get: { - queryKey: () => ['system-features'], - queryOptions: (options: Record = {}) => ({ - queryKey: ['system-features'], - ...options, - }), - }, - }, - apiBasedExtension: { - get: { - queryOptions: () => ({}), - }, - post: { - mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }), - }, - byId: { - post: { - mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }), - }, - delete: { - mutationOptions: () => ({ mutationFn: mockDeleteApiBasedExtension }), - }, - }, - }, - }, -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') return { ...actual, - useQuery: vi.fn(() => mockApiBasedExtensionsQuery()), - useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise }) => ({ - isPending: false, - mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => { - options.mutationFn(variables).then((data) => mutationOptions?.onSuccess?.(data)) - }, - })), + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apiBasedExtension.get': listExtensions, + 'apiBasedExtension.post': mockCreateApiBasedExtension, + 'apiBasedExtension.byId.post': mockUpdateApiBasedExtension, + 'apiBasedExtension.byId.delete': mockDeleteApiBasedExtension, + }), } }) @@ -75,60 +45,48 @@ describe('ApiBasedExtensionPage', () => { beforeEach(() => { vi.clearAllMocks() mockWorkspacePermissionKeys.current = ['api_extension.manage'] - mockApiBasedExtensionsQuery.mockReturnValue({ - data: [], - isPending: false, - }) + listExtensions.mockResolvedValue([]) }) describe('Rendering', () => { - it('should render empty state when no data exists', () => { + it('should render empty state when no data exists', async () => { // Arrange - mockApiBasedExtensionsQuery.mockReturnValue({ - data: [], - isPending: false, - }) + listExtensions.mockResolvedValue([]) // Act render() // Assert // Assert - expect(screen.getByText('common.apiBasedExtension.title'))!.toBeInTheDocument() + expect(await screen.findByText('common.apiBasedExtension.title'))!.toBeInTheDocument() }) - it('should render list of extensions when data exists', () => { + it('should render list of extensions when data exists', async () => { // Arrange const mockData: ApiBasedExtensionResponse[] = [ { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'key1' }, { id: '2', name: 'Extension 2', api_endpoint: 'url2', api_key: 'key2' }, ] - mockApiBasedExtensionsQuery.mockReturnValue({ - data: mockData, - isPending: false, - }) + listExtensions.mockResolvedValue(mockData) // Act render() // Assert // Assert - expect(screen.getByText('Extension 1'))!.toBeInTheDocument() - expect(screen.getByText('url1'))!.toBeInTheDocument() - expect(screen.getByText('Extension 2'))!.toBeInTheDocument() - expect(screen.getByText('url2'))!.toBeInTheDocument() + expect(await screen.findByText('Extension 1'))!.toBeInTheDocument() + expect(await screen.findByText('url1'))!.toBeInTheDocument() + expect(await screen.findByText('Extension 2'))!.toBeInTheDocument() + expect(await screen.findByText('url2'))!.toBeInTheDocument() }) - it('should pass search and add controls through the layout toolbar', () => { + it('should pass search and add controls through the layout toolbar', async () => { // Arrange const mockData: ApiBasedExtensionResponse[] = [ { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'key1' }, ] - mockApiBasedExtensionsQuery.mockReturnValue({ - data: mockData, - isPending: false, - }) + listExtensions.mockResolvedValue(mockData) // Act render( @@ -147,10 +105,10 @@ describe('ApiBasedExtensionPage', () => { screen.getByPlaceholderText('common.operation.search'), ) expect(screen.getByTestId('toolbar')).toHaveTextContent('common.apiBasedExtension.add') - expect(screen.getByText('Extension 1'))!.toBeInTheDocument() + expect(await screen.findByText('Extension 1'))!.toBeInTheDocument() }) - it('should filter extensions by search keywords', () => { + it('should filter extensions by search keywords', async () => { // Arrange const mockData: ApiBasedExtensionResponse[] = [ { @@ -166,10 +124,7 @@ describe('ApiBasedExtensionPage', () => { api_key: 'key2', }, ] - mockApiBasedExtensionsQuery.mockReturnValue({ - data: mockData, - isPending: false, - }) + listExtensions.mockResolvedValue(mockData) // Act render() @@ -178,7 +133,7 @@ describe('ApiBasedExtensionPage', () => { }) // Assert - expect(screen.getByText('Alpha Extension'))!.toBeInTheDocument() + expect(await screen.findByText('Alpha Extension'))!.toBeInTheDocument() expect(screen.queryByText('Beta Extension')).not.toBeInTheDocument() }) @@ -192,10 +147,7 @@ describe('ApiBasedExtensionPage', () => { api_key: 'key1', }, ] - mockApiBasedExtensionsQuery.mockReturnValue({ - data: mockData, - isPending: false, - }) + listExtensions.mockResolvedValue(mockData) // Act render() @@ -212,10 +164,7 @@ describe('ApiBasedExtensionPage', () => { it('should handle loading state', () => { // Arrange - mockApiBasedExtensionsQuery.mockReturnValue({ - data: undefined, - isPending: true, - }) + listExtensions.mockImplementation(() => new Promise(() => {})) // Act render() @@ -226,7 +175,7 @@ describe('ApiBasedExtensionPage', () => { expect(screen.getByText('common.apiBasedExtension.add'))!.toBeInTheDocument() }) - it('should disable management actions when api extension permission is missing', () => { + it('should disable management actions when api extension permission is missing', async () => { // Arrange mockWorkspacePermissionKeys.current = [] const extension: ApiBasedExtensionResponse = { @@ -235,10 +184,7 @@ describe('ApiBasedExtensionPage', () => { api_endpoint: 'url1', api_key: 'key1', } - mockApiBasedExtensionsQuery.mockReturnValue({ - data: [extension], - isPending: false, - }) + listExtensions.mockResolvedValue([extension]) // Act render() @@ -246,10 +192,10 @@ describe('ApiBasedExtensionPage', () => { // Assert expect(screen.getByRole('button', { name: 'common.apiBasedExtension.add' })).toBeDisabled() expect( - screen.getByRole('button', { name: /common\.operation\.edit.*Extension 1/i }), + await screen.findByRole('button', { name: /common\.operation\.edit.*Extension 1/i }), ).toBeDisabled() expect( - screen.getByRole('button', { name: /common\.operation\.delete.*Extension 1/i }), + await screen.findByRole('button', { name: /common\.operation\.delete.*Extension 1/i }), ).toBeDisabled() }) }) @@ -257,10 +203,7 @@ describe('ApiBasedExtensionPage', () => { describe('User Interactions', () => { it('should open modal when clicking add button', () => { // Arrange - mockApiBasedExtensionsQuery.mockReturnValue({ - data: [], - isPending: false, - }) + listExtensions.mockResolvedValue([]) // Act render() @@ -280,10 +223,7 @@ describe('ApiBasedExtensionPage', () => { api_endpoint: 'https://api.test', api_key: 'secret-key', }) - mockApiBasedExtensionsQuery.mockReturnValue({ - data: [], - isPending: false, - }) + listExtensions.mockResolvedValue([]) // Act render() @@ -326,15 +266,12 @@ describe('ApiBasedExtensionPage', () => { api_key: 'long-api-key', } mockUpdateApiBasedExtension.mockResolvedValue({ ...extension, name: 'Updated' }) - mockApiBasedExtensionsQuery.mockReturnValue({ - data: [extension], - isPending: false, - }) + listExtensions.mockResolvedValue([extension]) render() // Act - Click edit on the rendered item - fireEvent.click(screen.getByText('common.operation.edit')) + fireEvent.click(await screen.findByText('common.operation.edit')) fireEvent.click(screen.getByText('common.operation.save')) // Assert diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx index 0f07639f9d3af1..71abf45d59fb37 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/modal.spec.tsx @@ -1,11 +1,14 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen' +import type { QueryClient } from '@tanstack/react-query' import type { TFunction } from 'i18next' import type { ReactElement } from 'react' -import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as reactI18next from 'react-i18next' import { useDocLink } from '@/context/i18n' +import { createQueryClientWrapper } from '@/test/console/query-client' import { withSelectorKey } from '@/test/i18n-mock' +import { createTestQueryClient } from '@/test/query-client' import { ApiBasedExtensionModal } from '../modal' const { mockCreateApiBasedExtension, mockUpdateApiBasedExtension, mockToast } = vi.hoisted(() => { @@ -31,26 +34,21 @@ vi.mock('@/service/console', () => ({ consoleQuery: { apiBasedExtension: { post: { - mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }), + mutationOptions: () => ({ + mutationFn: (input: unknown) => mockCreateApiBasedExtension(input), + }), }, byId: { post: { - mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }), + mutationOptions: () => ({ + mutationFn: (input: unknown) => mockUpdateApiBasedExtension(input), + }), }, }, }, }, })) -vi.mock('@tanstack/react-query', () => ({ - useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise }) => ({ - isPending: false, - mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => { - options.mutationFn(variables).then((data) => mutationOptions?.onSuccess?.(data)) - }, - })), -})) - vi.mock('@/app/notifications', () => ({ toast: mockToast, })) @@ -69,7 +67,10 @@ describe('ApiBasedExtensionModal', () => { ...overrides, }) - const render = (ui: ReactElement) => RTLRender(ui) + let queryClient: QueryClient + const render = (ui: ReactElement) => + RTLRender(ui, { wrapper: createQueryClientWrapper(queryClient) }) + const renderModal = ( props: | { @@ -109,9 +110,14 @@ describe('ApiBasedExtensionModal', () => { beforeEach(() => { vi.clearAllMocks() + queryClient = createTestQueryClient() + mockCreateApiBasedExtension.mockReset() + mockUpdateApiBasedExtension.mockReset() vi.mocked(useDocLink).mockReturnValue(mockDocLink) }) + afterEach(() => queryClient.clear()) + describe('Rendering', () => { it('should render correctly for adding a new extension', () => { // Act @@ -196,6 +202,38 @@ describe('ApiBasedExtensionModal', () => { }) }) + it.each(['success', 'failure'] as const)( + 'prevents duplicate saves until a %s response settles', + async (outcome) => { + const user = userEvent.setup() + let resolveSave!: (value: ApiBasedExtensionResponse) => void + let rejectSave!: (reason: Error) => void + mockUpdateApiBasedExtension.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + resolveSave = resolve + rejectSave = reject + }), + ) + renderModal({ mode: 'edit', apiBasedExtension: mockExtension({ api_key: 'secret-key' }) }) + const save = screen.getByRole('button', { name: 'common.operation.save' }) + + await user.click(save) + await waitFor(() => expect(save).toBeDisabled()) + await user.click(save) + expect(mockUpdateApiBasedExtension).toHaveBeenCalledOnce() + expect(mockOnSaved).not.toHaveBeenCalled() + + await act(async () => { + if (outcome === 'success') resolveSave(mockExtension()) + else rejectSave(new Error('Unable to save')) + }) + await waitFor(() => expect(save).toBeEnabled()) + if (outcome === 'success') expect(mockOnSaved).toHaveBeenCalledOnce() + else expect(mockOnSaved).not.toHaveBeenCalled() + }, + ) + it('should call update mutation on save for existing extension', async () => { // Arrange const data = mockExtension({ api_key: 'long-secret-key' }) diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx index 785a1f9ea752af..a0b1ed225520b7 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx @@ -1,19 +1,16 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithConsoleQuery as renderWithoutUrlState } from '@/test/console/query-data' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { ApiBasedExtensionSelector } from '../selector' -const { mockApiBasedExtensionsQuery, mockCreateApiBasedExtension, mockUpdateApiBasedExtension } = - vi.hoisted(() => ({ - mockApiBasedExtensionsQuery: vi.fn(), +const { listExtensions, mockCreateApiBasedExtension, mockUpdateApiBasedExtension } = vi.hoisted( + () => ({ + listExtensions: vi.fn(), mockCreateApiBasedExtension: vi.fn(), mockUpdateApiBasedExtension: vi.fn(), - })) - -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) + }), +) vi.mock('@/context/i18n', () => ({ useDocLink: @@ -22,33 +19,21 @@ vi.mock('@/context/i18n', () => ({ `https://docs.dify.ai${path}`, })) -vi.mock('@/service/console', () => ({ - consoleQuery: { - apiBasedExtension: { - get: { - queryOptions: () => ({}), - }, - post: { - mutationOptions: () => ({ mutationFn: mockCreateApiBasedExtension }), - }, - byId: { - post: { - mutationOptions: () => ({ mutationFn: mockUpdateApiBasedExtension }), - }, - }, - }, - }, -})) - -vi.mock('@tanstack/react-query', () => ({ - useQuery: vi.fn(() => mockApiBasedExtensionsQuery()), - useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise }) => ({ - isPending: false, - mutate: (variables: unknown, mutationOptions?: { onSuccess?: (data: unknown) => void }) => { - options.mutationFn(variables).then((data) => mutationOptions?.onSuccess?.(data)) - }, - })), -})) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apiBasedExtension.get': listExtensions, + 'apiBasedExtension.post': mockCreateApiBasedExtension, + 'apiBasedExtension.byId.post': mockUpdateApiBasedExtension, + }), + } +}) describe('ApiBasedExtensionSelector', () => { const mockOnChange = vi.fn() @@ -60,11 +45,7 @@ describe('ApiBasedExtensionSelector', () => { beforeEach(() => { vi.clearAllMocks() - mockApiBasedExtensionsQuery.mockReturnValue({ - data: mockData, - isPending: false, - isError: false, - }) + listExtensions.mockResolvedValue(mockData) }) describe('Rendering', () => { @@ -83,7 +64,7 @@ describe('ApiBasedExtensionSelector', () => { // Assert // Assert - expect(screen.getByText('Extension 1'))!.toBeInTheDocument() + expect(await screen.findByText('Extension 1'))!.toBeInTheDocument() }) }) @@ -125,7 +106,9 @@ describe('ApiBasedExtensionSelector', () => { fireEvent.click(manageButton) // Assert - expect(mockSetSettingsDestination).toHaveBeenCalledWith('custom-endpoint') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('custom-endpoint'), + ) }) it('should open add modal when clicking add button and close it after save', async () => { diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx index 40cee33e2ab359..6fc77ef131dae9 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx @@ -7,7 +7,7 @@ import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' import { CollectionType } from '@/app/components/tools/types' import { useRenderI18nObject } from '@/hooks/use-i18n' import { openOAuthPopup } from '@/hooks/use-oauth' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import Card from '../card' import { useDataSourceAuthUpdate } from '../hooks' @@ -95,13 +95,16 @@ vi.mock('@/hooks/use-oauth', () => ({ const { mockGetPluginOAuthUrl } = vi.hoisted(() => ({ mockGetPluginOAuthUrl: vi.fn() })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'oauth.plugin.byProviderId.datasource.getAuthorizationUrl.get': () => ({ - mutateAsync: mockGetPluginOAuthUrl, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'oauth.plugin.byProviderId.datasource.getAuthorizationUrl.get': (input) => + mockGetPluginOAuthUrl(input), }), - }) + } }) vi.mock('../hooks', () => ({ diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index 624f8e2adb0f0e..05b87605dc367f 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -9,20 +9,17 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { renderWithConsoleQuery } from '@/test/console/query-data' import MembersPage from '../index' -const { mockMembersHook, mockWorkspaceRoleListHook, mockUpdateRolesOfMemberHook } = vi.hoisted( - () => ({ - mockMembersHook: vi.fn(), - mockWorkspaceRoleListHook: vi.fn(), - mockUpdateRolesOfMemberHook: vi.fn(), - }), -) +const { mockMembersHook, mockWorkspaceRoleListHook, mockUpdateRolesOfMember } = vi.hoisted(() => ({ + mockMembersHook: vi.fn(), + mockWorkspaceRoleListHook: vi.fn(), + mockUpdateRolesOfMember: vi.fn(), +})) vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { 'workspaces.current.members.get': () => mockMembersHook(), 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), - 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': () => mockUpdateRolesOfMemberHook(), }) }) @@ -47,31 +44,12 @@ vi.mock('@/hooks/use-format-time-from-now') const { inviteMember } = vi.hoisted(() => ({ inviteMember: vi.fn() })) vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') return { ...actual, - consoleQuery: new Proxy(actual.consoleQuery, { - get(target, key) { - if (key !== 'workspaces') return Reflect.get(target, key) - return { - current: { - summary: target.workspaces.current.summary, - permission: target.workspaces.current.permission, - rbac: target.workspaces.current.rbac, - members: { - get: target.workspaces.current.members.get, - inviteEmail: { - post: { - mutationOptions: ( - options: Parameters< - typeof actual.consoleQuery.workspaces.current.members.inviteEmail.post.mutationOptions - >[0], - ) => ({ ...options, mutationFn: inviteMember }), - }, - }, - }, - }, - } - }, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.members.inviteEmail.post': inviteMember, + 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': mockUpdateRolesOfMember, }), } }) @@ -191,7 +169,6 @@ vi.mock('@/app/components/billing/upgrade-btn', () => ({ describe('MembersPage', () => { const mockRefetch = vi.fn() const mockFormatTimeFromNow = vi.fn(() => 'just now') - const mockUpdateRolesOfMember = vi.fn() const mockAccounts: AccountWithRoleResponse[] = [ { @@ -237,13 +214,7 @@ describe('MembersPage', () => { data: { accounts: mockAccounts }, refetch: mockRefetch, }) - mockUpdateRolesOfMember.mockImplementation((_payload, options) => { - options?.onSuccess?.() - return Promise.resolve() - }) - vi.mocked(mockUpdateRolesOfMemberHook).mockReturnValue({ - mutateAsync: mockUpdateRolesOfMember, - }) + mockUpdateRolesOfMember.mockResolvedValue({ result: 'success' }) inviteMember.mockResolvedValue({ result: 'success', @@ -673,14 +644,11 @@ describe('MembersPage', () => { await user.click(getMemberDetailsButton('2')) await user.click(screen.getByRole('button', { name: 'Submit Member Roles' })) - expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( - { - params: { member_id: '2' }, - body: { role_ids: ['role-next'] }, - }, - expect.any(Object), - ) - expect(mockRefetch).toHaveBeenCalled() + expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({ + params: { member_id: '2' }, + body: { role_ids: ['role-next'] }, + }) + await waitFor(() => expect(mockRefetch).toHaveBeenCalled()) expect(screen.getByText('Member Details Modal')).toBeInTheDocument() expect(screen.getByTestId('details-member-name')).toHaveTextContent('Admin User') }) @@ -700,13 +668,10 @@ describe('MembersPage', () => { await user.click(getMemberDetailsButton('2')) await user.click(screen.getByRole('button', { name: 'Submit Member Roles' })) - expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( - { - params: { member_id: '2' }, - body: { role_ids: ['role-next', 'role-extra'] }, - }, - expect.any(Object), - ) + expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({ + params: { member_id: '2' }, + body: { role_ids: ['role-next', 'role-extra'] }, + }) }) it('should not open member details when clicking the member menu area', async () => { diff --git a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx index 6d946dbf8c91bb..82447df90d0771 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx @@ -7,33 +7,29 @@ import { consoleClient, consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' import MemberMenu from '../member-menu' -const { mockWorkspaceRoleListHook, mockUpdateRolesOfMemberHook } = vi.hoisted(() => ({ +const { mockWorkspaceRoleListHook, mockUpdateRolesOfMember } = vi.hoisted(() => ({ mockWorkspaceRoleListHook: vi.fn(), - mockUpdateRolesOfMemberHook: vi.fn(), + mockUpdateRolesOfMember: vi.fn(), })) vi.mock('@tanstack/react-query', async (importOriginal) => { const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') return mockConsoleOperationHooks(await importOriginal(), { 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), - 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': () => mockUpdateRolesOfMemberHook(), }) }) -vi.mock('@/service/console', async (importOriginal) => ({ - ...(await importOriginal()), - consoleClient: { - workspaces: { - current: { - members: { - byMemberId: { - delete: vi.fn(), - }, - }, - }, - }, - }, -})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: { workspaces: { current: { members: { byMemberId: { delete: vi.fn() } } } } }, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': mockUpdateRolesOfMember, + }), + } +}) vi.mock('@/app/notifications', () => ({ toast: { success: vi.fn(), @@ -73,8 +69,6 @@ const member: AccountWithRoleResponse = { } describe('MemberMenu', () => { - const mockUpdateRolesOfMember = vi.fn() - const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -95,9 +89,6 @@ describe('MemberMenu', () => { result: 'success', tenant_id: 'tenant-1', }) - vi.mocked(mockUpdateRolesOfMemberHook).mockReturnValue({ - mutateAsync: mockUpdateRolesOfMember, - }) vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ data: { pages: [ @@ -166,13 +157,10 @@ describe('MemberMenu', () => { await user.click(screen.getByRole('radio', { name: /Second role/i })) await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i })) - expect(mockUpdateRolesOfMember).toHaveBeenCalledWith( - { - params: { member_id: 'member-1' }, - body: { role_ids: ['role-2'] }, - }, - expect.any(Object), - ) + expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({ + params: { member_id: 'member-1' }, + body: { role_ids: ['role-2'] }, + }) }) it('should require confirmation before removing a member', async () => { diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx index 0a4153207338e7..30371c6507d004 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx @@ -1,18 +1,21 @@ import type { Role, RoleListResponse } from '@/models/access-control' -import { act, render, screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { RoleSelector } from '../role-selector' -const { mockWorkspaceRoleListHook } = vi.hoisted(() => ({ - mockWorkspaceRoleListHook: vi.fn(), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.rbac.roles.get': () => mockWorkspaceRoleListHook(), - }) +const getRoles = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.rbac.roles.get': getRoles, + }), + } }) const createRole = (overrides: Partial): Role => ({ @@ -44,29 +47,6 @@ const createPage = (data: Role[]): RoleListResponse => ({ }, }) -const mockUseWorkspaceRoleList = ({ - data = roles, - isLoading = false, - error = null, - hasNextPage = false, - fetchNextPage = vi.fn(), -}: { - data?: Role[] - isLoading?: boolean - error?: Error | null - hasNextPage?: boolean - fetchNextPage?: () => void -} = {}) => { - vi.mocked(mockWorkspaceRoleListHook).mockReturnValue({ - data: { pages: [createPage(data)], pageParams: [1] }, - isLoading, - error, - hasNextPage, - isFetchingNextPage: false, - fetchNextPage, - }) -} - const RoleSelectorWrapper = () => const getTrigger = () => screen.getByRole('combobox', { name: /members\.role/i }) @@ -75,7 +55,11 @@ const getListbox = () => screen.getByRole('listbox') describe('RoleSelector', () => { beforeEach(() => { vi.clearAllMocks() - mockUseWorkspaceRoleList() + getRoles.mockReset().mockResolvedValue(createPage(roles)) + }) + + afterEach(() => { + vi.unstubAllGlobals() }) it('requires an explicit selection and exposes select semantics', () => { @@ -107,12 +91,12 @@ describe('RoleSelector', () => { it('falls back to localized descriptions for legacy built-in roles', async () => { const user = userEvent.setup() - mockUseWorkspaceRoleList({ - data: [ + getRoles.mockResolvedValue( + createPage([ createRole({ id: 'admin', name: 'admin', description: '' }), createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }), - ], - }) + ]), + ) render() await user.click(getTrigger()) @@ -125,80 +109,86 @@ describe('RoleSelector', () => { ).toBeInTheDocument() }) - it('renders loading and empty states inside the select popup', async () => { + it('replaces the loading state with the empty state after the first page arrives', async () => { const user = userEvent.setup() - mockUseWorkspaceRoleList({ data: [], isLoading: true }) - const { rerender } = render() - + const request = createDeferred() + getRoles.mockReturnValue(request.promise) + render() await user.click(getTrigger()) expect(within(getListbox()).getByText(/common\.loading/i)).toBeInTheDocument() - - mockUseWorkspaceRoleList({ data: [] }) - rerender() - expect(within(getListbox()).getByText(/dynamicSelect\.noData/i)).toBeInTheDocument() + await act(async () => request.resolve(createPage([]))) + expect(await within(getListbox()).findByText(/dynamicSelect\.noData/i)).toBeInTheDocument() }) - it('shows an error instead of treating a failed role query as empty', async () => { + it('shows an error instead of treating a failed first page as empty', async () => { const user = userEvent.setup() - mockUseWorkspaceRoleList({ data: [], error: new Error('Failed to load roles') }) + getRoles.mockRejectedValue(new Error('Failed to load roles')) render() - await user.click(getTrigger()) - - expect(within(getListbox()).getByText(/dynamicSelect\.error/i)).toBeInTheDocument() + expect(await within(getListbox()).findByText(/dynamicSelect\.error/i)).toBeInTheDocument() expect(within(getListbox()).queryByText(/dynamicSelect\.noData/i)).not.toBeInTheDocument() - expect(within(getListbox()).queryByRole('button')).not.toBeInTheDocument() - }) - - it('keeps loaded roles available when a later request fails', async () => { - const user = userEvent.setup() - mockUseWorkspaceRoleList({ error: new Error('Failed to load another role page') }) - render() - - await user.click(getTrigger()) - - expect(within(getListbox()).getByRole('option', { name: /Admin/i })).toBeInTheDocument() - expect(within(getListbox()).queryByText(/dynamicSelect\.error/i)).not.toBeInTheDocument() }) - it('loads another role page when the list sentinel becomes visible', async () => { - const user = userEvent.setup() - const fetchNextPage = vi.fn() - const callbacks: IntersectionObserverCallback[] = [] - const originalIntersectionObserver = globalThis.IntersectionObserver - - globalThis.IntersectionObserver = class { - readonly root: Element | Document | null = null - readonly rootMargin = '' - readonly scrollMargin = '' - readonly thresholds: ReadonlyArray = [] - - constructor(callback: IntersectionObserverCallback) { - callbacks.push(callback) - } - - observe() {} - unobserve() {} - disconnect() {} - takeRecords(): IntersectionObserverEntry[] { - return [] - } - } - - mockUseWorkspaceRoleList({ hasNextPage: true, fetchNextPage }) - render() - - await user.click(getTrigger()) - await waitFor(() => expect(callbacks).toHaveLength(1)) - - await act(async () => { - callbacks[0]!( - [{ isIntersecting: true } as IntersectionObserverEntry], - {} as IntersectionObserver, + it.each([false, true])( + 'preserves loaded roles after requesting the next page (failure: %s)', + async (fails) => { + const user = userEvent.setup() + const callbacks: IntersectionObserverCallback[] = [] + vi.stubGlobal( + 'IntersectionObserver', + class { + readonly root: Element | Document | null = null + readonly rootMargin = '' + readonly scrollMargin = '' + readonly thresholds: ReadonlyArray = [] + + constructor(callback: IntersectionObserverCallback) { + callbacks.push(callback) + } + + observe() {} + unobserve() {} + disconnect() {} + takeRecords(): IntersectionObserverEntry[] { + return [] + } + }, ) - }) - expect(fetchNextPage).toHaveBeenCalledTimes(1) - globalThis.IntersectionObserver = originalIntersectionObserver - }) + const nextRole = createRole({ id: 'next-role', name: 'Additional role' }) + getRoles.mockResolvedValueOnce({ + ...createPage(roles), + pagination: { total_count: 4, per_page: 3, current_page: 1, total_pages: 2 }, + }) + if (fails) getRoles.mockRejectedValueOnce(new Error('Failed to load another role page')) + else + getRoles.mockResolvedValueOnce({ + ...createPage([nextRole]), + pagination: { total_count: 4, per_page: 3, current_page: 2, total_pages: 2 }, + }) + render() + + await user.click(getTrigger()) + await within(getListbox()).findByRole('option', { name: /Admin/i }) + await waitFor(() => expect(callbacks.length).toBeGreaterThan(0)) + + await act(async () => { + callbacks.at(-1)!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + + await waitFor(() => expect(getRoles).toHaveBeenCalledTimes(2)) + expect(getRoles).toHaveBeenLastCalledWith({ + query: expect.objectContaining({ page: 2, limit: 20 }), + }) + if (!fails) + expect( + await within(getListbox()).findByRole('option', { name: /Additional role/i }), + ).toBeInTheDocument() + expect(within(getListbox()).getByRole('option', { name: /Admin/i })).toBeInTheDocument() + expect(within(getListbox()).queryByText(/dynamicSelect\.error/i)).not.toBeInTheDocument() + }, + ) }) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts index 0ec65df363afeb..89b6480e5c699c 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts @@ -1,4 +1,5 @@ import type { ProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { RenderHookOptions } from '@testing-library/react' import type { Mock } from 'vite-plus/test' import type { Credential, @@ -7,9 +8,11 @@ import type { DefaultModelResponse, ModelProvider, } from '../declarations' -import { act, renderHook } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { useLocale } from '#i18n' import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { ConfigurationMethodEnum, CurrentSystemQuotaTypeEnum, @@ -41,12 +44,6 @@ vi.mock('#i18n', async (importOriginal) => ({ })) // Mock dependencies -vi.mock('@tanstack/react-query', () => ({ - useQuery: vi.fn(), - useQueryClient: vi.fn(() => ({ - invalidateQueries: vi.fn(), - })), -})) vi.mock('@/context/modal-context', () => ({ useModalContextSelector: vi.fn((selector) => { @@ -72,12 +69,28 @@ vi.mock('../atoms', () => ({ useExpandModelProviderList: vi.fn(() => vi.fn()), })) -const { useQuery, useQueryClient } = await import('@tanstack/react-query') -const { useModalContextSelector } = await import('@/context/modal-context') -const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = - await import('@/app/components/plugins/marketplace/hooks') -const { useExpandModelProviderList } = await import('../atoms') - +const { getDefaultModel, getModels } = vi.hoisted(() => ({ + getDefaultModel: vi.fn(), + getModels: vi.fn(), +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.defaultModel.get': getDefaultModel, + 'workspaces.current.models.modelTypes.byModelType.get': getModels, + }), + } +}) +let queryClient = createConsoleQueryClient() +function renderHook( + callback: (props: Props) => Result, + options: Omit, 'wrapper'> = {}, +) { + return renderHookWithConsoleQuery(callback, { ...options, queryClient }) +} const getModelListQueryKey = (modelType: ModelTypeEnum) => consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ input: { @@ -87,9 +100,29 @@ const getModelListQueryKey = (modelType: ModelTypeEnum) => }, }) +const seedModelList = (data: ProviderWithModelsResponse[]) => + queryClient.setQueryData<{ data: ProviderWithModelsResponse[] }>( + getModelListQueryKey(ModelTypeEnum.textGeneration), + { data }, + ) +const seedDefaultModel = (data: DefaultModelResponse) => + queryClient.setQueryData<{ data: DefaultModelResponse }>( + consoleQuery.workspaces.current.defaultModel.get.queryKey({ + input: { query: { model_type: ModelTypeEnum.textGeneration } }, + }), + { data }, + ) +const { useModalContextSelector } = await import('@/context/modal-context') +const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = + await import('@/app/components/plugins/marketplace/hooks') +const { useExpandModelProviderList } = await import('../atoms') + describe('hooks', () => { beforeEach(() => { vi.clearAllMocks() + queryClient = createConsoleQueryClient() + getDefaultModel.mockReset().mockImplementation(() => new Promise(() => {})) + getModels.mockReset().mockImplementation(() => new Promise(() => {})) }) describe('useLanguage', () => { @@ -265,87 +298,54 @@ describe('hooks', () => { }) describe('useDefaultModel', () => { - const mockDefaultModel = { + const defaultModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, - provider: { provider: 'openai', icon_small: { en_US: 'icon', zh_Hans: 'icon' } }, + provider: { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + label: { en_US: 'OpenAI' }, + supported_model_types: [], + tenant_id: 'tenant-1', + }, } - - it('should fetch default model successfully', async () => { - const refetch = vi.fn() - ;(useQuery as Mock).mockReturnValue({ - data: { data: mockDefaultModel }, - isPending: false, - refetch, - }) - + it('loads the default model and exposes the resolved response', async () => { + const request = createDeferred<{ data: typeof defaultModel }>() + getDefaultModel.mockReturnValue(request.promise) const { result } = renderHook(() => useDefaultModel(ModelTypeEnum.textGeneration)) - - expect(result.current.data).toEqual(mockDefaultModel) - expect(result.current.isLoading).toBe(false) - }) - - it('should return undefined when data is not available', () => { - ;(useQuery as Mock).mockReturnValue({ - data: undefined, - isPending: false, - refetch: vi.fn(), - }) - - const { result } = renderHook(() => useDefaultModel(ModelTypeEnum.textGeneration)) - + expect(result.current.isLoading).toBe(true) expect(result.current.data).toBeUndefined() + await act(async () => request.resolve({ data: defaultModel })) + await waitFor(() => expect(result.current.data).toEqual(defaultModel)) + expect(result.current.isLoading).toBe(false) + expect(getDefaultModel).toHaveBeenCalledWith({ query: { model_type: 'llm' } }) }) - - it('should keep the query disabled when requested', () => { - ;(useQuery as Mock).mockReturnValue({ - data: undefined, - isPending: true, - refetch: vi.fn(), - }) - + it('does not fetch or show loading when disabled', async () => { const { result } = renderHook(() => useDefaultModel(ModelTypeEnum.textEmbedding, { enabled: false }), ) - - expect(useQuery).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: false, - queryKey: consoleQuery.workspaces.current.defaultModel.get.queryKey({ - input: { query: { model_type: ModelTypeEnum.textEmbedding } }, - }), - }), - ) + await act(async () => {}) + expect(getDefaultModel).not.toHaveBeenCalled() expect(result.current.isLoading).toBe(false) }) - - it('should handle loading state', () => { - ;(useQuery as Mock).mockReturnValue({ - data: undefined, - isPending: true, - refetch: vi.fn(), - }) - + it('refreshes a cached default through mutate without returning to initial loading', async () => { + seedDefaultModel(defaultModel) const { result } = renderHook(() => useDefaultModel(ModelTypeEnum.textGeneration)) - - expect(result.current.isLoading).toBe(true) - }) - - it('should call mutate to refetch data', () => { - const refetch = vi.fn() - ;(useQuery as Mock).mockReturnValue({ - data: { data: mockDefaultModel }, - isPending: false, - refetch, - }) - - const { result } = renderHook(() => useDefaultModel(ModelTypeEnum.textGeneration)) - + const request = createDeferred<{ data: typeof defaultModel }>() + getDefaultModel.mockReturnValue(request.promise) act(() => { - result.current.mutate() + void result.current.mutate() }) - - expect(refetch).toHaveBeenCalled() + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toEqual(defaultModel) + await act(async () => request.resolve({ data: { ...defaultModel, model: 'gpt-5' } })) + await waitFor(() => expect(result.current.data?.model).toBe('gpt-5')) + }) + it('represents an unset server default as undefined', async () => { + getDefaultModel.mockResolvedValue({ data: null }) + const { result } = renderHook(() => useDefaultModel(ModelTypeEnum.textGeneration)) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toBeUndefined() }) }) @@ -471,11 +471,7 @@ describe('hooks', () => { it('should return all text generation model lists', () => { const modelList = createModelList() - ;(useQuery as Mock).mockReturnValue({ - data: modelList, - isPending: false, - refetch: vi.fn(), - }) + seedModelList(modelList) const defaultModel = { provider: 'openai', model: 'gpt-4' } const { result } = renderHook(() => @@ -489,11 +485,7 @@ describe('hooks', () => { it('should filter active models correctly', () => { const modelList = createModelList() - ;(useQuery as Mock).mockReturnValue({ - data: modelList, - isPending: false, - refetch: vi.fn(), - }) + seedModelList(modelList) const { result } = renderHook(() => useTextGenerationCurrentProviderAndModelAndModelList()) @@ -503,11 +495,7 @@ describe('hooks', () => { it('should find current provider and model', () => { const modelList = createModelList() - ;(useQuery as Mock).mockReturnValue({ - data: modelList, - isPending: false, - refetch: vi.fn(), - }) + seedModelList(modelList) const defaultModel = { provider: 'openai', model: 'gpt-4' } const { result } = renderHook(() => @@ -519,11 +507,7 @@ describe('hooks', () => { }) it('should handle empty model list', () => { - ;(useQuery as Mock).mockReturnValue({ - data: [], - isPending: false, - refetch: vi.fn(), - }) + seedModelList([]) const { result } = renderHook(() => useTextGenerationCurrentProviderAndModelAndModelList()) @@ -534,15 +518,27 @@ describe('hooks', () => { describe('useModelListAndDefaultModel', () => { it('should return both model list and default model', () => { - const mockModelData = [{ provider: 'openai', models: [] }] - const mockDefaultModel = { model: 'gpt-4', provider: { provider: 'openai' } } - ;(useQuery as Mock) - .mockReturnValueOnce({ data: mockModelData, isPending: false, refetch: vi.fn() }) - .mockReturnValueOnce({ - data: { data: mockDefaultModel }, - isPending: false, - refetch: vi.fn(), - }) + const mockModelData: ProviderWithModelsResponse[] = [ + { + provider: 'openai', + models: [], + tenant_id: 'tenant-1', + label: { en_US: 'OpenAI' }, + status: 'active', + }, + ] + const mockDefaultModel: DefaultModelResponse = { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + provider: { + provider: 'openai', + label: { en_US: 'OpenAI' }, + supported_model_types: [], + tenant_id: 'tenant-1', + }, + } + seedModelList(mockModelData) + seedDefaultModel(mockDefaultModel) const { result } = renderHook(() => useModelListAndDefaultModel(ModelTypeEnum.textGeneration)) @@ -551,10 +547,6 @@ describe('hooks', () => { }) it('should handle undefined values', () => { - ;(useQuery as Mock) - .mockReturnValueOnce({ data: undefined, isPending: false, refetch: vi.fn() }) - .mockReturnValueOnce({ data: undefined, isPending: false, refetch: vi.fn() }) - const { result } = renderHook(() => useModelListAndDefaultModel(ModelTypeEnum.textGeneration)) expect(result.current.modelList).toEqual([]) @@ -564,7 +556,7 @@ describe('hooks', () => { describe('useModelListAndDefaultModelAndCurrentProviderAndModel', () => { it('should return complete data structure', () => { - const mockModelData = [ + const mockModelData: ProviderWithModelsResponse[] = [ { tenant_id: 'test-workspace', provider: 'openai', @@ -587,15 +579,16 @@ describe('hooks', () => { const mockDefaultModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, - provider: { provider: 'openai', icon_small: { en_US: 'icon', zh_Hans: 'icon' } }, + provider: { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + label: { en_US: 'OpenAI' }, + supported_model_types: [], + tenant_id: 'tenant-1', + }, } - ;(useQuery as Mock) - .mockReturnValueOnce({ data: mockModelData, isPending: false, refetch: vi.fn() }) - .mockReturnValueOnce({ - data: { data: mockDefaultModel }, - isPending: false, - refetch: vi.fn(), - }) + seedModelList(mockModelData) + seedDefaultModel(mockDefaultModel) const { result } = renderHook(() => useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration), @@ -608,16 +601,16 @@ describe('hooks', () => { }) it('should handle missing default model', () => { - const mockModelData = [ + const mockModelData: ProviderWithModelsResponse[] = [ { provider: 'openai', models: [], + tenant_id: 'tenant-1', + label: { en_US: 'OpenAI' }, status: ModelStatusEnum.active, }, ] - ;(useQuery as Mock) - .mockReturnValueOnce({ data: mockModelData, isPending: false, refetch: vi.fn() }) - .mockReturnValueOnce({ data: undefined, isPending: false, refetch: vi.fn() }) + seedModelList(mockModelData) const { result } = renderHook(() => useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration), @@ -630,8 +623,7 @@ describe('hooks', () => { describe('useUpdateModelList', () => { it('should invalidate model list queries', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const { result } = renderHook(() => useUpdateModelList()) @@ -645,8 +637,7 @@ describe('hooks', () => { }) it('should handle multiple model types', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const { result } = renderHook(() => useUpdateModelList()) @@ -662,8 +653,7 @@ describe('hooks', () => { describe('useInvalidateDefaultModel', () => { it('should invalidate default model queries', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const { result } = renderHook(() => useInvalidateDefaultModel()) @@ -679,8 +669,7 @@ describe('hooks', () => { }) it('should handle multiple model types', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const { result } = renderHook(() => useInvalidateDefaultModel()) @@ -696,8 +685,7 @@ describe('hooks', () => { describe('useUpdateModelProviders', () => { it('should invalidate model providers queries', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const { result } = renderHook(() => useUpdateModelProviders()) @@ -714,8 +702,7 @@ describe('hooks', () => { }) it('should be callable multiple times', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const { result } = renderHook(() => useUpdateModelProviders()) @@ -985,8 +972,7 @@ describe('hooks', () => { }) it('should refresh providers and model lists', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const provider = createMockProvider() const modelProviderModelListQueryKey = @@ -1023,9 +1009,8 @@ describe('hooks', () => { }) it('should expand target provider list when refreshModelList is true and custom config is active', () => { - const invalidateQueries = vi.fn() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const expandModelProviderList = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) ;(useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList) const provider = createMockProvider() @@ -1060,9 +1045,8 @@ describe('hooks', () => { }) it('should not expand provider list when custom config is not active', () => { - const invalidateQueries = vi.fn() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const expandModelProviderList = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) ;(useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList) const provider = { @@ -1093,8 +1077,7 @@ describe('hooks', () => { }) it('should refetch active model provider list when custom refresh callback is absent', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const provider = createMockProvider() const modelProviderModelListQueryKey = @@ -1119,8 +1102,7 @@ describe('hooks', () => { }) it('should invalidate all supported model types when __model_type is undefined', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const provider = createMockProvider() const customFields = { @@ -1142,8 +1124,7 @@ describe('hooks', () => { }) it('should handle provider with single model type', () => { - const invalidateQueries = vi.fn() - ;(useQueryClient as Mock).mockReturnValue({ invalidateQueries }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') const provider = { ...createMockProvider(), diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx index 8770adb2b6fd8c..ec141b6cbadbc6 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx @@ -21,13 +21,6 @@ const providerSummaryFixture = { }, } satisfies ModelProviderSummaryResponse -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - } -}) - vi.mock('../hooks', () => ({ useDefaultModel: () => ({ data: null, isLoading: false }), useLanguage: () => 'en_US', diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 5b935d0a31aae9..f233b948fcf4f9 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -8,7 +8,11 @@ import { STEP_BY_STEP_TOUR_TARGETS, } from '@/app/components/step-by-step-tour/target-registry' import { consoleQuery } from '@/service/console' -import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { + createConsoleQueryClient, + renderWithConsoleQuery as renderWithConsoleQueryWithoutUrlState, +} from '@/test/console/query-data' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { CurrentSystemQuotaTypeEnum, CustomConfigurationStatusEnum, @@ -45,8 +49,7 @@ type MockReferenceSetting = { } } -const { mockSetSettingsDestination, mockSaveAutoUpgrade } = vi.hoisted(() => ({ - mockSetSettingsDestination: vi.fn(), +const { mockSaveAutoUpgrade } = vi.hoisted(() => ({ mockSaveAutoUpgrade: vi.fn(), })) @@ -215,6 +218,12 @@ const mockProviders: MockProvider[] = [ }, ] +const onUrlUpdate = vi.fn() +const renderWithConsoleQuery = createNuqsTestRenderer( + renderWithConsoleQueryWithoutUrlState, + () => ({ onUrlUpdate }), +) + const renderModelProviderPage = ( props: { enableMarketplace?: boolean @@ -357,11 +366,6 @@ vi.mock('@/service/use-plugins', () => ({ }), })) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const originalWorkspaces = actual.consoleQuery.workspaces diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx index 568a87558fb650..ce965d7a6f22fe 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/credential-selector.spec.tsx @@ -16,10 +16,6 @@ vi.mock('../authorized/credential-item', () => ({ ), })) -vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: () =>
, -})) - vi.mock('@remixicon/react', () => ({ RiAddLine: () =>
, RiArrowDownSLine: () =>
, @@ -46,7 +42,6 @@ describe('CredentialSelector', () => { ) expect(screen.getByText('Key 1')).toBeInTheDocument() - expect(screen.getByTestId('indicator')).toBeInTheDocument() }) it('should render placeholder when selectedCredential is missing', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx index 6f00a022d6645b..d0a687cca58008 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx @@ -54,10 +54,6 @@ vi.mock('../authorized', () => ({ ), })) -vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status: string }) =>
, -})) - vi.mock('@remixicon/react', () => ({ RiArrowDownSLine: () =>
, RiQuestionLine: () =>
, @@ -98,7 +94,6 @@ describe('SwitchCredentialInLoadBalancing', () => { ) expect(screen.getByText('Key 1'))!.toBeInTheDocument() - expect(screen.getByTestId('indicator-success'))!.toBeInTheDocument() }) it('should render auth removed status when selected credential is not in list', () => { @@ -113,7 +108,6 @@ describe('SwitchCredentialInLoadBalancing', () => { ) expect(screen.getByText(/modelProvider.auth.authRemoved/))!.toBeInTheDocument() - expect(screen.getByTestId('indicator-error'))!.toBeInTheDocument() }) it('should render add credential status when credentials list is empty and create is allowed', () => { @@ -128,7 +122,6 @@ describe('SwitchCredentialInLoadBalancing', () => { ) expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument() - expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument() }) it('should call setCustomModelCredential when an item is selected in Authorized', () => { @@ -204,7 +197,7 @@ describe('SwitchCredentialInLoadBalancing', () => { }) // not_allowed_to_use=true: indicator is red and destructive button text is shown - it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => { + it('should show unavailable button text when credential has not_allowed_to_use=true', () => { const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', @@ -221,7 +214,6 @@ describe('SwitchCredentialInLoadBalancing', () => { />, ) - expect(screen.getByTestId('indicator-error'))!.toBeInTheDocument() expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument() }) @@ -296,7 +288,6 @@ describe('SwitchCredentialInLoadBalancing', () => { // credentials is undefined -> empty=true -> add credential text shown when creation is allowed. expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument() - expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument() }) it('should render nothing for credential_name when it is empty string', () => { @@ -312,9 +303,6 @@ describe('SwitchCredentialInLoadBalancing', () => { />, ) - // indicator-success shown (not authRemoved, not unavailable, not empty) - // indicator-success shown (not authRemoved, not unavailable, not empty) - expect(screen.getByTestId('indicator-success'))!.toBeInTheDocument() // credential_name is empty so nothing printed for name // credential_name is empty so nothing printed for name // credential_name is empty so nothing printed for name diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx index 66050cb3c23075..0a5627727117c2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/credential-item.spec.tsx @@ -3,12 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import CredentialItem from '../credential-item' -vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status?: string }) => ( -
- ), -})) - describe('CredentialItem', () => { const credential: Credential = { credential_id: 'cred-1', @@ -19,11 +13,10 @@ describe('CredentialItem', () => { vi.clearAllMocks() }) - it('should render credential text and indicator', () => { + it('should render credential text', () => { render() expect(screen.getByText('Test API Key')).toBeInTheDocument() - expect(screen.getByTestId('indicator')).toBeInTheDocument() }) it('should render enterprise badge for enterprise credential', () => { @@ -110,19 +103,12 @@ describe('CredentialItem', () => { expect(onItemClick).not.toHaveBeenCalled() }) - it('should render unavailable credential with error indicator and unavailable label', () => { + it('should render unavailable credential label', () => { render() - expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'error') expect(screen.getByText('modelProvider.modelProvider.card.unavailable')).toBeInTheDocument() }) - it('should render available credential with success indicator', () => { - render() - - expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success') - }) - it('should call onEdit and onDelete from action buttons', () => { const onEdit = vi.fn() const onDelete = vi.fn() diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx index d5fdd8b3c8d984..8076485b0e26f9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-auth.spec.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from 'react' import type { Credential, CustomModel, ModelProvider } from '../../../declarations' import { act, renderHook } from '@testing-library/react' +import { createConsoleQueryWrapper } from '@/test/console/query-data' import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../../declarations' import { useAuth } from '../use-auth' @@ -38,21 +38,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useRefreshModel: () => ({ handleRefreshModel: mockHandleRefreshModel }), })) -vi.mock('@/service/console', () => ({ - consoleQuery: { - workspaces: { - current: { - modelProviders: { byProvider: { models: { delete: { mutationOptions: () => ({}) } } } }, - }, - }, - }, -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => ({ - ...(await importOriginal()), - useMutation: () => ({ mutateAsync: mockDeleteModelService }), -})) - vi.mock('../use-auth-service', () => ({ useAuthService: () => ({ getDeleteCredentialService: (isModel: boolean) => @@ -90,10 +75,11 @@ describe('useAuth', () => { model_type: ModelTypeEnum.textGeneration, } - const createWrapper = ({ children }: { children: ReactNode }) => <>{children} + let createWrapper = createConsoleQueryWrapper().wrapper beforeEach(() => { vi.clearAllMocks() + createWrapper = createConsoleQueryWrapper().wrapper mockDeleteModelService.mockResolvedValue({ result: 'success' }) mockDeleteProviderCredential.mockResolvedValue({ result: 'success' }) mockDeleteModelCredential.mockResolvedValue({ result: 'success' }) @@ -402,3 +388,15 @@ describe('useAuth', () => { expect(mockActiveProviderCredential).toHaveBeenCalledTimes(1) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.modelProviders.byProvider.models.delete': (input) => + mockDeleteModelService(input), + }), + } +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx index b553bf01d62017..8ddbd320da2579 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/__tests__/use-model-form-schemas.spec.tsx @@ -1,24 +1,9 @@ import type { Credential, CustomModelCredential, ModelProvider } from '../../../declarations' import { renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vite-plus/test' +import { describe, expect, it } from 'vite-plus/test' import { FormTypeEnum } from '@/app/components/base/form/types' import { useModelFormSchemas } from '../use-model-form-schemas' -vi.mock('../../../utils', () => ({ - genModelNameFormSchema: vi.fn(() => ({ - type: FormTypeEnum.textInput, - variable: '__model_name', - label: 'Model Name', - required: true, - })), - genModelTypeFormSchema: vi.fn(() => ({ - type: FormTypeEnum.select, - variable: '__model_type', - label: 'Model Type', - required: true, - })), -})) - describe('useModelFormSchemas', () => { const mockProvider = { provider: 'openai', diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx index 3da2ca569f81ad..6a5105111c3865 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx @@ -18,10 +18,6 @@ vi.mock('@/context/workspace-state', async () => { })) }) -vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ - default: () =>
, -})) - vi.mock('../../model-auth', () => ({ CredentialSelector: ({ credentials }: { credentials: Credential[] }) => (
{`credentials:${credentials.length}`}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx index 2781de32fa9d05..c1654e6d54fdd1 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx @@ -1,12 +1,13 @@ import type * as React from 'react' import type { Credential, CredentialFormSchema, ModelProvider } from '../../declarations' -import type { FormSchema } from '@/app/components/base/form/types' -import { fireEvent, screen, waitFor, within } from '@testing-library/react' -import { render } from '@/test/console/render' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { ConfigurationMethodEnum, CurrentSystemQuotaTypeEnum, CustomConfigurationStatusEnum, + FormTypeEnum, ModelModalModeEnum, ModelTypeEnum, PreferredProviderTypeEnum, @@ -46,15 +47,6 @@ const mockHandlers = vi.hoisted(() => ({ handleActiveCredential: vi.fn(), })) -type FormResponse = { - isCheckValidated: boolean - values: Record -} -const mockFormState = vi.hoisted(() => ({ - responses: [] as FormResponse[], - setFieldValue: vi.fn(), -})) - vi.mock('../../model-auth/hooks', () => ({ useCredentialData: () => ({ isLoading: mockState.isLoading, @@ -92,66 +84,6 @@ vi.mock('../../hooks', () => ({ useLanguage: () => 'en_US', })) -vi.mock('@/app/components/base/form/form-scenarios/auth', async () => { - const React = await import('react') - const AuthForm = React.forwardRef( - ( - { - formSchemas, - onChange, - }: { - formSchemas: FormSchema[] - onChange?: (field: string, value: string) => void - }, - ref: React.ForwardedRef<{ - getFormValues: () => FormResponse - getForm: () => { setFieldValue: (field: string, value: string) => void } - }>, - ) => { - React.useImperativeHandle(ref, () => ({ - getFormValues: () => - mockFormState.responses.shift() || { isCheckValidated: false, values: {} }, - getForm: () => ({ setFieldValue: mockFormState.setFieldValue }), - })) - return ( -
- {formSchemas.map( - (schema) => - typeof schema.description === 'string' && ( -
{schema.description}
- ), - )} - -
- ) - }, - ) - - return { default: AuthForm } -}) - -vi.mock('../../model-auth', () => ({ - CredentialSelector: ({ - onSelect, - }: { - onSelect: (credential: Credential & { addNewCredential?: boolean }) => void - }) => ( -
- - -
- ), -})) - const createI18n = (text: string) => ({ en_US: text, zh_Hans: text }) const createProvider = (overrides?: Partial): ModelProvider => ({ @@ -210,340 +142,127 @@ const renderModal = (overrides?: Partial } } -describe('ModelModal', () => { - beforeEach(() => { - vi.clearAllMocks() - mockState.isLoading = false - mockState.credentialData = { credentials: {}, available_credentials: [] } - mockState.doingAction = false - mockState.deleteCredentialId = null - mockState.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage'] - mockState.formSchemas = [] - mockState.formValues = {} - mockState.modelNameAndTypeFormSchemas = [] - mockState.modelNameAndTypeFormValues = {} - mockFormState.responses = [] - }) - - it('should show title, description, and loading state for predefined models', () => { - mockState.isLoading = true - - const predefined = renderModal() - - expect( - screen.getByRole('dialog', { name: 'modelProvider.modelProvider.auth.apiKeyModal.title' }), - ).toBeInTheDocument() - expect( - screen.getByText('modelProvider.modelProvider.auth.apiKeyModal.desc'), - )!.toBeInTheDocument() - expect(screen.getByRole('progressbar'))!.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' }))!.toBeDisabled() - - predefined.unmount() - const customizable = renderModal({ - configurateMethod: ConfigurationMethodEnum.customizableModel, - }) - expect( - screen.queryByText('modelProvider.modelProvider.auth.apiKeyModal.desc'), - ).not.toBeInTheDocument() - customizable.unmount() - - mockState.credentialData = { credentials: {}, available_credentials: [] } - renderModal({ - mode: ModelModalModeEnum.configModelCredential, - model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }, - }) - expect( - screen.getByText('modelProvider.modelProvider.auth.addModelCredential'), - )!.toBeInTheDocument() - }) - - it('should reveal the credential label when adding a new credential', () => { - renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList }) - - expect( - screen.queryByText('modelProvider.modelProvider.auth.modelCredential'), - ).not.toBeInTheDocument() +const credentialField = ( + variable: string, + label: string, + type: FormTypeEnum = FormTypeEnum.textInput, +): CredentialFormSchema => ({ + name: variable, + variable, + label: createI18n(label), + type, + required: true, + show_on: [], +}) +beforeEach(() => { + mockState.formSchemas = [ + credentialField('__authorization_name__', 'Authorization name'), + credentialField('api_key', 'API key', FormTypeEnum.secretInput), + ] + mockState.formValues = { __authorization_name__: '', api_key: '' } + mockState.modelNameAndTypeFormSchemas = [] + mockState.modelNameAndTypeFormValues = {} + mockState.credentialData = { credentials: {}, available_credentials: [] } +}) - fireEvent.click(screen.getByText('Add New')) +const fillCredentials = async (user: ReturnType) => { + await user.type(screen.getByRole('textbox', { name: /Authorization name/ }), 'My credential') + await user.type(screen.getByLabelText(/API key/), 'secret') +} - expect( - screen.getByText('modelProvider.modelProvider.auth.modelCredential'), - )!.toBeInTheDocument() +it('blocks invalid credentials and submits the values entered in the real form', async () => { + const user = userEvent.setup() + const { onSave } = renderModal() + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled() + await fillCredentials(user) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => + expect(mockHandlers.handleSaveCredential).toHaveBeenCalledExactlyOnceWith({ + credential_id: undefined, + credentials: { api_key: 'secret' }, + name: 'My credential', + }), + ) + expect(onSave).toHaveBeenCalledWith({ + __authorization_name__: 'My credential', + api_key: 'secret', }) +}) - it('should show the Responses API tip only for official OpenAI credential forms', () => { - const tip = 'modelProvider.modelProvider.auth.openAIResponsesAPITip' - mockState.formSchemas = [ - { variable: 'api_protocol', type: 'select' } as unknown as CredentialFormSchema, - ] - - const officialOpenAI = renderModal({ - provider: createProvider({ provider: 'langgenius/openai/openai' }), - }) - expect(screen.getByText(tip))!.toBeInTheDocument() - officialOpenAI.unmount() - - const legacyOpenAI = renderModal() - expect(screen.getByText(tip))!.toBeInTheDocument() - legacyOpenAI.unmount() - - mockState.formSchemas = [] - const openAIWithoutProtocolField = renderModal() - expect(screen.queryByText(tip)).not.toBeInTheDocument() - openAIWithoutProtocolField.unmount() - - mockState.formSchemas = [ - { variable: 'api_protocol', type: 'select' } as unknown as CredentialFormSchema, - ] +it.each(['model', 'fixed'] as const)( + 'uses the %s model context when saving a model credential', + async (context) => { + const user = userEvent.setup() renderModal({ - provider: createProvider({ provider: 'langgenius/azure_openai/azure_openai' }), - }) - expect(screen.queryByText(tip)).not.toBeInTheDocument() - }) - - it('should show the Responses API tip when adding a new custom model credential', () => { - const tip = 'modelProvider.modelProvider.auth.openAIResponsesAPITip' - mockState.formSchemas = [ - { variable: 'api_protocol', type: 'select' } as unknown as CredentialFormSchema, - ] - const customModel = renderModal({ mode: ModelModalModeEnum.configCustomModel }) - expect(screen.getByText(tip))!.toBeInTheDocument() - customModel.unmount() - - const modelCredential = renderModal({ mode: ModelModalModeEnum.configModelCredential, - model: { model: 'gpt-5.6', model_type: ModelTypeEnum.textGeneration }, + ...(context === 'model' + ? { model: { model: 'bge-m3', model_type: ModelTypeEnum.textEmbedding } } + : { + currentCustomConfigurationModelFixedFields: { + __model_name: 'bge-m3', + __model_type: ModelTypeEnum.textEmbedding, + }, + }), }) - expect(screen.getByText(tip))!.toBeInTheDocument() - modelCredential.unmount() - - renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList }) - - expect(screen.queryByText(tip)).not.toBeInTheDocument() - - fireEvent.click(screen.getByText('Add New')) - - expect(screen.getByText(tip))!.toBeInTheDocument() - }) - - it('should call onCancel when the cancel button is clicked', () => { - const { onCancel } = renderModal() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - expect(onCancel).toHaveBeenCalledTimes(1) - }) - - it('should call onCancel when the escape key is pressed', () => { - const { onCancel } = renderModal() - - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(onCancel).toHaveBeenCalledTimes(1) - }) - - it('should confirm deletion when a delete dialog is shown', () => { - mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] } - mockState.deleteCredentialId = 'delete-id' - - const credential: Credential = { credential_id: 'cred-1' } - const { onCancel } = renderModal({ credential }) - - const alertDialog = screen.getByRole('alertdialog', { hidden: true }) - expect(alertDialog)!.toHaveTextContent('modelProvider.modelProvider.confirmDelete') - - fireEvent.click( - within(alertDialog).getByRole('button', { hidden: true, name: 'common.operation.confirm' }), - ) - - expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1) - expect(onCancel).toHaveBeenCalledTimes(1) - }) - - it('should handle save flows for different modal modes', async () => { - mockState.modelNameAndTypeFormSchemas = [ - { variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema, - ] - mockState.formSchemas = [ - { variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema, - ] - mockFormState.responses = [ - { - isCheckValidated: true, - values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration }, - }, - { - isCheckValidated: true, - values: { __authorization_name__: 'Auth Name', api_key: 'secret' }, - }, - ] - const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel }) - fireEvent.click(screen.getAllByText('Model Name Change')[0]!) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - - expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model') - await waitFor(() => { + await fillCredentials(user) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ credential_id: undefined, credentials: { api_key: 'secret' }, - name: 'Auth Name', - model: 'custom-model', - model_type: ModelTypeEnum.textGeneration, - }) - }) - expect(configCustomModel.onSave).toHaveBeenCalledWith({ - __authorization_name__: 'Auth Name', - api_key: 'secret', - }) - configCustomModel.unmount() - - mockFormState.responses = [ - { isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }, - ] - const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } - const configModelCredential = renderModal({ - mode: ModelModalModeEnum.configModelCredential, - model, - credential: { credential_id: 'cred-123' }, - }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ - credential_id: 'cred-123', - credentials: { api_key: 'abc' }, - name: 'Model Auth', - model: 'gpt-4', - model_type: ModelTypeEnum.textGeneration, - }) - }) - expect(configModelCredential.onSave).toHaveBeenCalledWith({ - __authorization_name__: 'Model Auth', - api_key: 'abc', - }) - configModelCredential.unmount() - - mockFormState.responses = [ - { - isCheckValidated: true, - values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' }, - }, - ] - const configProviderCredential = renderModal({ - mode: ModelModalModeEnum.configProviderCredential, - }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ - credential_id: undefined, - credentials: { api_key: 'provider-key' }, - name: 'Provider Auth', - }) - }) - configProviderCredential.unmount() - - const addToModelList = renderModal({ - mode: ModelModalModeEnum.addCustomModelToModelList, - model, - }) - fireEvent.click(screen.getByText('Choose Existing')) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith( - { credential_id: 'existing' }, - model, + name: 'My credential', + model: 'bge-m3', + model_type: ModelTypeEnum.textEmbedding, + }), ) - expect(addToModelList.onCancel).toHaveBeenCalled() - addToModelList.unmount() + }, +) + +it('does not save a model credential without model context', async () => { + const user = userEvent.setup() + renderModal({ mode: ModelModalModeEnum.configModelCredential }) + await fillCredentials(user) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled() +}) - mockFormState.responses = [ - { - isCheckValidated: true, - values: { __authorization_name__: 'New Auth', api_key: 'new-key' }, - }, - ] - const addToModelListWithNew = renderModal({ - mode: ModelModalModeEnum.addCustomModelToModelList, - model, - }) - fireEvent.click(screen.getByText('Add New')) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ - credential_id: undefined, - credentials: { api_key: 'new-key' }, - name: 'New Auth', - model: 'gpt-4', +it('validates both forms before saving a custom model', async () => { + const user = userEvent.setup() + mockState.modelNameAndTypeFormSchemas = [ + credentialField('__model_name', 'Model name'), + credentialField('__model_type', 'Model type'), + ] + mockState.modelNameAndTypeFormValues = { + __model_name: '', + __model_type: ModelTypeEnum.textGeneration, + } + renderModal({ mode: ModelModalModeEnum.configCustomModel }) + await fillCredentials(user) + await user.click(screen.getByRole('button', { name: 'common.operation.add' })) + expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled() + await user.type(screen.getByRole('textbox', { name: /Model name/ }), 'custom-model') + await user.click(screen.getByRole('button', { name: 'common.operation.add' })) + await waitFor(() => + expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'custom-model', model_type: ModelTypeEnum.textGeneration, - }) - }) - addToModelListWithNew.unmount() - - mockFormState.responses = [{ isCheckValidated: false, values: {} }] - const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4) - }) - invalidSave.unmount() - - mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] } - mockState.formValues = { api_key: 'value' } - const removable = renderModal({ credential: { credential_id: 'remove-1' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) - expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith( - { credential_id: 'remove-1' }, - undefined, - ) - removable.unmount() - }) - - it('should use fixed model context when saving a model credential without model prop', async () => { - mockState.formSchemas = [ - { variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema, - ] - mockFormState.responses = [ - { - isCheckValidated: true, - values: { __authorization_name__: 'Xinference Auth', api_key: 'secret' }, - }, - ] - - renderModal({ - mode: ModelModalModeEnum.configModelCredential, - currentCustomConfigurationModelFixedFields: { - __model_name: 'bge-m3', - __model_type: ModelTypeEnum.textEmbedding, - }, - }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({ - credential_id: undefined, - credentials: { api_key: 'secret' }, - name: 'Xinference Auth', - model: 'bge-m3', - model_type: ModelTypeEnum.textEmbedding, - }) - }) - }) + credentials: { api_key: 'secret', __model_name: 'custom-model' }, + }), + ), + ) +}) - it('should not submit model credential payload when model context is missing', async () => { +it.each(['openai', 'langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'])( + 'limits Responses API guidance to official OpenAI providers: %s', + (provider) => { mockState.formSchemas = [ - { variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema, + { ...credentialField('api_protocol', 'Protocol', FormTypeEnum.select), options: [] }, ] - mockFormState.responses = [ - { - isCheckValidated: true, - values: { __authorization_name__: 'Missing Model Auth', api_key: 'secret' }, - }, - ] - - renderModal({ mode: ModelModalModeEnum.configModelCredential }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled() - }) - }) -}) + renderModal({ provider: createProvider({ provider }) }) + const tip = screen.queryByText('modelProvider.modelProvider.auth.openAIResponsesAPITip') + if (provider.includes('azure_openai')) expect(tip).not.toBeInTheDocument() + else expect(tip).toBeInTheDocument() + }, +) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx index 581ee1ab25d450..82238bee7523cb 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx @@ -1,5 +1,7 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import ModelParameterModal from '../index' let parameterRules: Array> | undefined = [ @@ -13,8 +15,8 @@ let parameterRules: Array> | undefined = [ help: { en_US: 'Control randomness' }, }, ] -let isRulesLoading = false -let isRulesPending = false +let seedRules = true +const getRules = vi.fn() let currentProvider: Record | undefined = { provider: 'openai', label: { en_US: 'OpenAI' }, @@ -121,18 +123,6 @@ vi.mock('../presets-parameter', () => ({ }, })) -vi.mock('../presets-parameter-utils', () => ({ - getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => { - if (supportedParameterNames && !supportedParameterNames.includes('temperature')) return {} - - return { temperature: 0.8 } - }, -})) - -vi.mock('../trigger', () => ({ - default: () => , -})) - vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { @@ -141,17 +131,26 @@ vi.mock('@/config', async (importOriginal) => { } }) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': () => ({ - data: { - data: parameterRules, - }, - isLoading: isRulesLoading, - isPending: isRulesPending, +const render = (ui: React.ReactElement) => { + const queryClient = createConsoleQueryClient() + if (seedRules) + queryClient.setQueryDefaults( + consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(), + { initialData: { data: parameterRules ?? [] } }, + ) + return renderWithConsoleQuery(ui, { queryClient }) +} + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (input) => + getRules(input), }), - }) + } }) describe('ModelParameterModal', () => { @@ -172,8 +171,8 @@ describe('ModelParameterModal', () => { beforeEach(() => { vi.clearAllMocks() - isRulesLoading = false - isRulesPending = false + seedRules = true + getRules.mockReturnValue(new Promise(() => {})) parameterRules = [ { name: 'temperature', @@ -338,21 +337,21 @@ describe('ModelParameterModal', () => { }) it('should render loading state when parameter rules are loading', () => { - isRulesLoading = true - isRulesPending = true + seedRules = false render() openSettings() expect(screen.getByRole('progressbar')).toBeInTheDocument() }) it('should not render parameter loading when model is not configured and parameter rules query is pending but disabled', () => { - isRulesPending = true + seedRules = false parameterRules = [] render() openSettings() expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(getRules).not.toHaveBeenCalled() expect(screen.getByTestId('model-selector')).toBeInTheDocument() }) @@ -441,8 +440,7 @@ describe('ModelParameterModal', () => { it('should render the empty loading fallback when rules resolve to an empty list', () => { parameterRules = [] - isRulesLoading = true - isRulesPending = true + seedRules = false render() openSettings() diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx index b1683f2129daa8..b5d132f739649e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/parameter-item.spec.tsx @@ -9,14 +9,6 @@ vi.mock('../../hooks', () => ({ useLanguage: () => 'en_US', })) -vi.mock('@/app/components/base/tag-input', () => ({ - default: ({ onChange }: { onChange: (v: string[]) => void }) => ( - - ), -})) - let promptEditorOnChange: ((text: string) => void) | undefined let capturedWorkflowNodesMap: Record | undefined @@ -193,7 +185,8 @@ describe('ParameterItem', () => { expect(screen.getByText('a'))!.toBeInTheDocument() }) - it('should render tag input for tag type', () => { + it('adds a stop sequence on Tab without replacing existing sequences', async () => { + const user = userEvent.setup() const onChange = vi.fn() render( { />, ) expect(screen.getByText('placeholder'))!.toBeInTheDocument() - fireEvent.click(screen.getByTestId('tag-input')) - expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2']) + await user.type(screen.getByRole('textbox'), 'STOP') + await user.tab() + expect(onChange).toHaveBeenCalledWith(['a', 'STOP']) }) it('should render boolean radios and update value on click', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx index 04a3c89a749868..ee9918378cd8e8 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx @@ -6,10 +6,11 @@ import type { import type { ReactNode } from 'react' import type { DefaultModelResponse } from '../../declarations' import { QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render as renderWithoutUrlState, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { consoleQuery } from '@/service/console' import { createConsoleQueryClient } from '@/test/console/query-data' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../../declarations' import { useSystemDefaultModelAndModelList } from '../../hooks' import { ModelSelector, SplitModelSelector } from '../index' @@ -28,15 +29,6 @@ const makeModelItem = ( }) const mockModelProviders = vi.hoisted(() => ({ current: [] as ProviderWithModelsResponse[] })) -const mockSetSettingsDestination = vi.hoisted(() => vi.fn()) - -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: () => [null, mockSetSettingsDestination], - } -}) vi.mock('../../provider-added-card/use-credential-panel-state', () => ({ useCredentialPanelState: () => ({ @@ -115,6 +107,9 @@ const makeProviderSummary = (): ModelProviderSummaryResponse => ({ system_configuration: { enabled: true }, }) +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + const renderWithQueryClient = (node: ReactNode) => { const queryClient = createConsoleQueryClient() queryClient.setQueryData( @@ -227,7 +222,9 @@ describe('ModelSelector', () => { expect(triggerButton).toHaveAttribute('aria-expanded', 'false') expect(onHide).toHaveBeenCalledTimes(1) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'), + ) }) it('should not open popup when disabled', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx index d209f8af35ca8a..08af44876d0c04 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx @@ -46,11 +46,6 @@ vi.mock( }), ) -const mockSupportFunctionCall = vi.hoisted(() => vi.fn()) -vi.mock('@/utils/tool-call', () => ({ - supportFunctionCall: mockSupportFunctionCall, -})) - type MockSummaryProvider = Pick< ModelProviderSummaryResponse, | 'provider' @@ -194,9 +189,6 @@ vi.mock('next-themes', () => ({ })) const mockInstallMutateAsync = vi.hoisted(() => vi.fn()) -vi.mock('@/service/use-plugins', () => ({ - useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }), -})) const mockFetchPluginInfoFromMarketPlace = vi.hoisted(() => vi.fn()) vi.mock('@/service/plugins', () => ({ @@ -280,11 +272,22 @@ const makeSummaryProvider = ( ...overrides, }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.install.marketplace.post': (...args: unknown[]) => + mockInstallMutateAsync(...args), + }), + } +}) + describe('Popup', () => { beforeEach(() => { vi.clearAllMocks() mockLanguage = 'en_US' - mockSupportFunctionCall.mockReturnValue(true) mockFetchPluginInfoFromMarketPlace.mockResolvedValue({ data: { plugin: { @@ -674,8 +677,6 @@ describe('Popup', () => { }) it('should still apply scope features when direct props match by provider label', () => { - mockSupportFunctionCall.mockReturnValue(false) - renderPopup( { target: { value: 'openai' }, }) - expect( - screen.getByText(/modelProvider\.modelProvider\.selector\.noModelFoundForSearch.*openai/), - )!.toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() - expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument() + expect(screen.getByText('gpt-4-tool')).toBeInTheDocument() }) it('should toggle incompatible models from the reveal action', async () => { @@ -891,8 +889,6 @@ describe('Popup', () => { }) it('should still apply scope features when matching by provider label', () => { - mockSupportFunctionCall.mockReturnValue(false) - renderPopup( { target: { value: 'openai' }, }) - expect( - screen.getByText(/modelProvider\.modelProvider\.selector\.noModelFoundForSearch/), - )!.toBeInTheDocument() expect(screen.queryByText('gpt-4')).not.toBeInTheDocument() - expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument() + expect(screen.getByText('gpt-4-tool')).toBeInTheDocument() }) it('should not show compatible-only helper text when no scope features are applied', () => { @@ -974,10 +967,11 @@ describe('Popup', () => { }), ] - mockSupportFunctionCall.mockReturnValue(false) const { unmount } = renderPopup( , @@ -987,7 +981,6 @@ describe('Popup', () => { )!.toBeInTheDocument() unmount() - mockSupportFunctionCall.mockReturnValue(true) const { unmount: unmount2 } = renderPopup( { fireEvent.click(installButtons[0]!) await waitFor(() => { - expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0') + expect(mockInstallMutateAsync).toHaveBeenCalledWith({ + body: { plugin_unique_identifiers: ['langgenius/openai:1.0.0'] }, + }) }) expect(mockFetchPluginInfoFromMarketPlace).toHaveBeenCalledWith({ org: 'langgenius', diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx index 8421b457094980..17b7a19c5adba2 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx @@ -105,12 +105,6 @@ vi.mock('../model-auth-dropdown', () => ({ ), })) -vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status: string }) => ( -
- ), -})) - const createProvider = (overrides: Partial = {}): ModelProvider => ({ provider: 'langgenius/openai/openai', @@ -211,24 +205,22 @@ describe('CredentialPanel', () => { }) describe('Status label variants', () => { - it('should show green indicator and credential name for api-fallback (exhausted + authorized key)', () => { + it('should show credential name for api-fallback (exhausted + authorized key)', () => { mockTrialCredits.isExhausted = true renderWithQueryClient(createProvider()) - expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success') expect(screen.getByText('test-credential')).toBeInTheDocument() }) - it('should show green indicator for api-active (custom priority + authorized)', () => { + it('should show credential name for api-active (custom priority + authorized)', () => { renderWithQueryClient( createProvider({ preferred_provider_type: PreferredProviderTypeEnum.custom, }), ) - expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success') expect(screen.getByText('test-credential')).toBeInTheDocument() }) - it('should show red indicator and credential name for api-unavailable (exhausted + named unauthorized key)', () => { + it('should show unavailable credential name for api-unavailable (exhausted + named unauthorized key)', () => { mockTrialCredits.isExhausted = true renderWithQueryClient( createProvider({ @@ -241,7 +233,6 @@ describe('CredentialPanel', () => { }, }), ) - expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'error') expect(screen.getByText('Bad Key')).toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx index 975b56312eaaca..168a541ed3e47e 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-load-balancing-modal.spec.tsx @@ -1,6 +1,7 @@ import type { ModelItem, ModelProvider } from '../../declarations' -import { render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { ConfigurationMethodEnum } from '../../declarations' import ModelLoadBalancingModal from '../model-load-balancing-modal' @@ -72,41 +73,6 @@ vi.mock('@/app/notifications', async (importOriginal) => { } }) -vi.mock('@/service/console', () => ({ - consoleQuery: { - workspaces: { - current: { - modelProviders: { - byProvider: { - models: { - credentials: { get: { queryOptions: () => ({ operation: 'model-credential' }) } }, - post: { mutationOptions: () => ({ operation: 'update-load-balancing' }) }, - }, - }, - }, - }, - }, - }, -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: (options: { operation?: string }) => - options.operation === 'model-credential' - ? { isLoading: false, data: mockCredentialData, refetch: mockRefetch } - : actual.useQuery(options as Parameters[0]), - useMutation: (options: { operation?: string }) => - options.operation === 'update-load-balancing' - ? { - mutateAsync: ({ params, body }: { params: { provider: string }; body: unknown }) => - mockMutateAsync(body, params), - } - : actual.useMutation(options as Parameters[0]), - } -}) - vi.mock('../../model-auth/hooks/use-auth', () => ({ useAuth: () => ({ doingAction: false, @@ -867,3 +833,26 @@ describe('ModelLoadBalancingModal', () => { }) }) }) + +// Credential snapshots are inputs to the local editable form; saves use the real mutation. +vi.mock('@tanstack/react-query', async (importOriginal) => { + const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') + return mockConsoleOperationHooks(await importOriginal(), { + 'workspaces.current.modelProviders.byProvider.models.credentials.get': () => ({ + isLoading: false, + data: mockCredentialData, + refetch: mockRefetch, + }), + }) +}) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.modelProviders.byProvider.models.post': ({ params, body }) => + mockMutateAsync(body, params), + }), + } +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx index 1ded58e4ff4cb4..942d821a202530 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/provider-card-actions.spec.tsx @@ -18,12 +18,10 @@ const mockShowPluginInfo = vi.fn() const mockShowDeleteConfirm = vi.fn() const mockSetTargetVersion = vi.fn() const mockSetVersionPickerOpen = vi.fn() -const { mockNormalizeInstalledPluginDetail, mockUninstallPlugin, mockUseVersionListOfPlugin } = - vi.hoisted(() => ({ - mockNormalizeInstalledPluginDetail: vi.fn(), - mockUninstallPlugin: vi.fn(), - mockUseVersionListOfPlugin: vi.fn(), - })) +const { mockUninstallPlugin, mockUseVersionListOfPlugin } = vi.hoisted(() => ({ + mockUninstallPlugin: vi.fn(), + mockUseVersionListOfPlugin: vi.fn(), +})) const mockPluginSettingsAccess = vi.hoisted(() => ({ canDeletePlugin: true, canUpdatePlugin: true, @@ -110,8 +108,8 @@ vi.mock('@/service/plugins', () => ({ uninstallPlugin: mockUninstallPlugin, })) -vi.mock('@/service/use-plugins', () => ({ - normalizeInstalledPluginDetail: mockNormalizeInstalledPluginDetail, +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), useVersionListOfPlugin: mockUseVersionListOfPlugin, })) @@ -177,11 +175,27 @@ const pluginInstallationsResponse = zPostWorkspacesCurrentPluginListInstallation ], }) +let detailResponse = structuredClone(pluginInstallationsResponse) +const setPluginDetail = (detail: PluginDetail) => { + const original = pluginInstallationsResponse.plugins[0]! + detailResponse = { + plugins: [ + { + ...original, + source: detail.source, + version: detail.version, + meta: detail.meta ?? {}, + declaration: { ...original.declaration, version: detail.version }, + }, + ], + } +} + const seedPluginDetail = (queryClient: QueryClient) => { const options = consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryOptions({ input: { body: { plugin_ids: ['langgenius/provider-plugin'] } }, }) - queryClient.setQueryData(options.queryKey, pluginInstallationsResponse) + queryClient.setQueryData(options.queryKey, detailResponse) } describe('ProviderCardActions', () => { @@ -208,7 +222,7 @@ describe('ProviderCardActions', () => { mockGetMarketplaceUrl.mockReturnValue( 'https://marketplace.example.com/plugins/langgenius/provider-plugin', ) - mockNormalizeInstalledPluginDetail.mockReturnValue( + setPluginDetail( createDetail({ source: PluginSource.github, meta: { @@ -255,7 +269,6 @@ describe('ProviderCardActions', () => { await waitFor(() => { expect(mockShowPluginInfo).toHaveBeenCalledTimes(1) }) - expect(mockNormalizeInstalledPluginDetail).toHaveBeenCalledTimes(1) }) it('should remove from summary without loading plugin detail', async () => { @@ -271,7 +284,6 @@ describe('ProviderCardActions', () => { await waitFor(() => { expect(mockUninstallPlugin).toHaveBeenCalledWith('installation-id') }) - expect(mockNormalizeInstalledPluginDetail).not.toHaveBeenCalled() expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: consoleQuery.workspaces.current.modelProviders.summary.get.key(), }) @@ -319,7 +331,7 @@ describe('ProviderCardActions', () => { }) it('should use the summary latest version when updating a marketplace plugin', async () => { - mockNormalizeInstalledPluginDetail.mockReturnValue( + setPluginDetail( createDetail({ latest_version: '1.0.0', latest_unique_identifier: 'plugin-id@1.0.0', @@ -359,7 +371,7 @@ describe('ProviderCardActions', () => { resolveRefresh = resolve }) const onUpdate = vi.fn(() => refreshPromise) - mockNormalizeInstalledPluginDetail.mockReturnValue(createDetail({ version: current })) + setPluginDetail(createDetail({ version: current })) const rendered = render( { const user = userEvent.setup() const refreshError = new Error('refresh failed') const onUpdate = vi.fn().mockRejectedValue(refreshError) - mockNormalizeInstalledPluginDetail.mockReturnValue(createDetail({ version: '1.0.0' })) + setPluginDetail(createDetail({ version: '1.0.0' })) const rendered = render( ({ - mockUseQuery: vi.fn(), -})) - -vi.mock('@tanstack/react-query', () => ({ - useQuery: (options: unknown) => mockUseQuery(options), -})) +const getCredits = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.modelProviders.credits.get': getCredits, + }), + } +}) -vi.mock('@/service/console', () => ({ - consoleQuery: { - workspaces: { - current: { - modelProviders: { - credits: { - get: { - queryOptions: (options?: object) => ({ - queryKey: ['console', 'workspaces', 'current', 'model-providers', 'credits', 'get'], - ...options, - }), - }, - }, - }, - }, - }, - }, -})) +const createCredits = ( + overrides: Partial = {}, +): ModelProviderCreditsResponse => ({ + quota_limit: 100, + quota_used: 40, + remaining_credits: 60, + is_unlimited: false, + is_exhausted: false, + exhausted_at: null, + next_credit_reset_date: 1775001600, + pool_type: 'trial', + ...overrides, +}) +const renderCredits = (data?: ModelProviderCreditsResponse) => { + const queryClient = createConsoleQueryClient() + if (data) + queryClient.setQueryData( + consoleQuery.workspaces.current.modelProviders.credits.get.queryKey(), + data, + ) + return renderHookWithConsoleQuery(() => useTrialCredits(), { queryClient }) +} describe('useTrialCredits', () => { - const mockTrialCreditsQuery = ( - data: - | { - quota_limit?: number | null - quota_used?: number | null - remaining_credits?: number | null - is_unlimited?: boolean - is_exhausted?: boolean - exhausted_at?: number | null - next_credit_reset_date?: number | null - } - | undefined, - isPending = false, - ) => { - mockUseQuery.mockImplementation((options: { select?: (value: typeof data) => unknown }) => ({ - data: data && options.select ? options.select(data) : data, - isPending, - })) - } - beforeEach(() => { - vi.clearAllMocks() - mockTrialCreditsQuery({ - quota_limit: 100, - quota_used: 40, - remaining_credits: 60, - is_exhausted: false, - exhausted_at: undefined, - next_credit_reset_date: 1775001600, - }) + getCredits.mockReset().mockImplementation(() => new Promise(() => {})) }) - describe('when workspace data is available', () => { - it('should return the remaining credits and reset date', () => { - const { result } = renderHook(() => useTrialCredits()) - - expect(result.current).toEqual({ - credits: 60, - usedCredits: 40, - totalCredits: 100, - isUnlimited: false, - isExhausted: false, - isLoading: false, - exhaustedAt: undefined, - nextCreditResetDate: 1775001600, - }) + it('returns the remaining credits and reset date', () => { + const { result } = renderCredits(createCredits()) + expect(result.current).toEqual({ + credits: 60, + usedCredits: 40, + totalCredits: 100, + isUnlimited: false, + isExhausted: false, + isLoading: false, + exhaustedAt: undefined, + nextCreditResetDate: 1775001600, }) + }) - it('should keep the hook out of loading state during a background refetch', () => { - mockTrialCreditsQuery( - { - quota_limit: 80, - quota_used: 20, - remaining_credits: 60, - is_exhausted: false, - next_credit_reset_date: 1777593600, - }, - true, - ) - - const { result } = renderHook(() => useTrialCredits()) - - expect(result.current.isLoading).toBe(false) - expect(result.current.credits).toBe(60) - expect(result.current.usedCredits).toBe(20) - expect(result.current.isUnlimited).toBe(false) - expect(result.current.isExhausted).toBe(false) + it('keeps cached credits visible while a background refetch is pending and updates after it settles', async () => { + const request = createDeferred() + getCredits.mockReturnValue(request.promise) + const { result, queryClient } = renderCredits(createCredits()) + act(() => { + void queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.modelProviders.credits.get.queryKey(), + }) }) + await waitFor(() => expect(getCredits).toHaveBeenCalledOnce()) + expect(result.current.isLoading).toBe(false) + expect(result.current.credits).toBe(60) + await act(async () => request.resolve(createCredits({ quota_used: 50, remaining_credits: 50 }))) + await waitFor(() => expect(result.current.credits).toBe(50)) + expect(result.current.usedCredits).toBe(50) + expect(result.current.isLoading).toBe(false) }) - describe('when workspace data is missing or exhausted', () => { - it('should report loading while the first workspace request is pending', () => { - mockTrialCreditsQuery(undefined, true) - - const { result } = renderHook(() => useTrialCredits()) - - expect(result.current).toEqual({ - credits: 0, - usedCredits: 0, - totalCredits: 0, - isUnlimited: false, - isExhausted: true, - isLoading: true, - exhaustedAt: undefined, - nextCreditResetDate: undefined, - }) + it('reports loading before the first response and settles with server credits', async () => { + const request = createDeferred() + getCredits.mockReturnValue(request.promise) + const { result } = renderCredits() + expect(result.current).toEqual({ + credits: 0, + usedCredits: 0, + totalCredits: 0, + isUnlimited: false, + isExhausted: true, + isLoading: true, + exhaustedAt: undefined, + nextCreditResetDate: undefined, }) + await act(async () => request.resolve(createCredits())) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.credits).toBe(60) + }) - it('should use the backend exhausted state', () => { - mockTrialCreditsQuery({ + it('uses the backend exhausted state', () => { + const { result } = renderCredits( + createCredits({ quota_limit: 10, quota_used: 10, remaining_credits: 0, is_exhausted: true, exhausted_at: 1772323200, - next_credit_reset_date: undefined, - }) - - const { result } = renderHook(() => useTrialCredits()) - - expect(result.current.credits).toBe(0) - expect(result.current.usedCredits).toBe(10) - expect(result.current.isExhausted).toBe(true) - expect(result.current.exhaustedAt).toBe(1772323200) + }), + ) + expect(result.current).toMatchObject({ + credits: 0, + usedCredits: 10, + isExhausted: true, + exhaustedAt: 1772323200, }) + }) - it('should preserve the unlimited state without interpreting sentinel credits as usage', () => { - mockTrialCreditsQuery({ + it('preserves unlimited credits without interpreting sentinel values as usage', () => { + const { result } = renderCredits( + createCredits({ quota_limit: -1, quota_used: 999, remaining_credits: -1, is_unlimited: true, is_exhausted: false, - exhausted_at: null, next_credit_reset_date: null, - }) - - const { result } = renderHook(() => useTrialCredits()) - - expect(result.current).toMatchObject({ - credits: -1, - usedCredits: 999, - totalCredits: -1, - isUnlimited: true, - isExhausted: false, - }) + }), + ) + expect(result.current).toMatchObject({ + credits: -1, + usedCredits: 999, + totalCredits: -1, + isUnlimited: true, + isExhausted: false, }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx index 8dac237b01af9d..1d05347b1f2c74 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx @@ -1,30 +1,13 @@ import type { Credential, ModelProvider } from '../../../declarations' -import { act, renderHook } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { toast } from '@/app/notifications' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { useActivateCredential } from '../use-activate-credential' -const mockMutate = - vi.fn<(_variables: unknown, callbacks: { onSuccess: () => void; onError: () => void }) => void>() +const switchCredential = vi.fn() const mockUpdateModelProviders = vi.fn() const mockUpdateModelList = vi.fn() -let mockIsPending = false - -vi.mock('@/service/console', () => ({ - consoleQuery: { - workspaces: { - current: { - modelProviders: { - byProvider: { credentials: { switch: { post: { mutationOptions: () => ({}) } } } }, - }, - }, - }, - }, -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => ({ - ...(await importOriginal()), - useMutation: () => ({ mutate: mockMutate, isPending: mockIsPending }), -})) vi.mock('../../../hooks', () => ({ useUpdateModelProviders: () => mockUpdateModelProviders, @@ -57,7 +40,7 @@ describe('useActivateCredential', () => { beforeEach(() => { vi.clearAllMocks() - mockIsPending = false + switchCredential.mockReset().mockResolvedValue({ result: 'success' }) toastErrorSpy.mockClear() }) @@ -68,12 +51,14 @@ describe('useActivateCredential', () => { expect(result.current.isActivating).toBe(false) }) - it('should expose the pending mutation state', () => { - mockIsPending = true - + it('exposes pending state until the activation request settles', async () => { + const request = createDeferred() + switchCredential.mockReturnValue(request.promise) const { result } = renderHook(() => useActivateCredential(createProvider())) - - expect(result.current.isActivating).toBe(true) + act(() => result.current.activate(createCredential())) + await waitFor(() => expect(result.current.isActivating).toBe(true)) + await act(async () => request.resolve({ result: 'success' })) + await waitFor(() => expect(result.current.isActivating).toBe(false)) }) it('should skip mutation when the selected credential is already active', () => { @@ -83,53 +68,50 @@ describe('useActivateCredential', () => { result.current.activate(createCredential({ credential_id: 'cred-1' })) }) - expect(mockMutate).not.toHaveBeenCalled() + expect(switchCredential).not.toHaveBeenCalled() expect(result.current.selectedCredentialId).toBe('cred-1') }) - it('should optimistically select the credential and refresh provider data on success', () => { + it('optimistically selects the credential and refreshes provider data after success', async () => { + const request = createDeferred() + switchCredential.mockReturnValue(request.promise) const { result } = renderHook(() => useActivateCredential(createProvider())) - - act(() => { - result.current.activate(createCredential()) - }) - + act(() => result.current.activate(createCredential())) expect(result.current.selectedCredentialId).toBe('cred-2') - expect(mockMutate).toHaveBeenCalledWith( - { params: { provider: 'langgenius/openai/openai' }, body: { credential_id: 'cred-2' } }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), + await waitFor(() => + expect(switchCredential).toHaveBeenCalledWith({ + params: { provider: 'langgenius/openai/openai' }, + body: { credential_id: 'cred-2' }, }), ) - - const [, callbacks] = mockMutate.mock.calls[0]! - - act(() => { - callbacks.onSuccess() - }) - - expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1) + expect(mockUpdateModelProviders).not.toHaveBeenCalled() + await act(async () => request.resolve({ result: 'success' })) + await waitFor(() => expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1)) expect(mockUpdateModelList).toHaveBeenNthCalledWith(1, 'llm') expect(mockUpdateModelList).toHaveBeenNthCalledWith(2, 'text-embedding') }) - it('should reset the optimistic selection and show an error toast when activation fails', () => { + it('rolls back optimistic selection and reports a failed activation', async () => { + const request = createDeferred() + switchCredential.mockReturnValue(request.promise) const { result } = renderHook(() => useActivateCredential(createProvider())) - - act(() => { - result.current.activate(createCredential()) - }) - + act(() => result.current.activate(createCredential())) expect(result.current.selectedCredentialId).toBe('cred-2') - - const [, callbacks] = mockMutate.mock.calls[0]! - - act(() => { - callbacks.onError() - }) - - expect(result.current.selectedCredentialId).toBe('cred-1') + await act(async () => request.reject(new Error('Activation failed'))) + await waitFor(() => expect(result.current.selectedCredentialId).toBe('cred-1')) expect(toastErrorSpy).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully') + expect(mockUpdateModelProviders).not.toHaveBeenCalled() }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.modelProviders.byProvider.credentials.switch.post': (input) => + switchCredential(input), + }), + } +}) diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx index a8715ea616babe..e5df09c20bfcae 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx @@ -2,11 +2,16 @@ import type { DefaultModelResponse } from '../../declarations' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vite-plus/test' -import { consoleQuery } from '@/service/console' -import { renderWithNuqs as render } from '@/test/nuqs-testing' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { renderWithNuqs } from '@/test/nuqs-testing' import { ModelTypeEnum } from '../../declarations' import SystemModel from '../index' +const render = (ui: React.ReactElement, options?: Parameters[1]) => { + const { wrapper: QueryWrapper } = createConsoleQueryWrapper() + return renderWithNuqs({ui}, options) +} + vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') return createReactI18nextMock({ @@ -35,7 +40,7 @@ const mockToastSuccess = vi.hoisted(() => vi.fn()) const mockUpdateModelList = vi.hoisted(() => vi.fn()) const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn()) const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' }))) -const mockModelListQuery = vi.hoisted(() => vi.fn()) +const getModelList = vi.hoisted(() => vi.fn()) const mockModelSelectorProps = vi.hoisted( () => [] as Array<{ @@ -76,8 +81,12 @@ vi.mock('../../hooks', async (importOriginal) => { vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') return { ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.models.modelTypes.byModelType.get': getModelList, + }), consoleClient: { workspaces: { current: { @@ -154,7 +163,7 @@ const defaultProps = { describe('SystemModel', () => { beforeEach(() => { vi.clearAllMocks() - mockModelListQuery.mockReturnValue({ data: [], isPending: false }) + getModelList.mockResolvedValue({ data: [] }) mockModelSelectorProps.length = 0 mockWorkspacePermissionKeys = ['plugin.model_config'] }) @@ -189,14 +198,9 @@ describe('SystemModel', () => { expect( screen.getByRole('button', { name: /system model settings/i, hidden: true }), ).toHaveAttribute('aria-expanded', 'true') - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.textEmbedding } }, - }), - enabled: true, - }), - ) + expect(getModelList).toHaveBeenCalledWith({ + params: { model_type: ModelTypeEnum.textEmbedding }, + }) }) it('clears only the dialog URL state when closed', async () => { @@ -216,80 +220,32 @@ describe('SystemModel', () => { const user = userEvent.setup() render() - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.textEmbedding } }, - }), - enabled: false, - }), - ) - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.rerank } }, - }), - enabled: false, - }), - ) - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.speech2text } }, - }), - enabled: false, - }), - ) - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.tts } }, - }), - enabled: false, - }), - ) + expect(getModelList).not.toHaveBeenCalledWith({ + params: { model_type: ModelTypeEnum.textEmbedding }, + }) + expect(getModelList).not.toHaveBeenCalledWith({ params: { model_type: ModelTypeEnum.rerank } }) + expect(getModelList).not.toHaveBeenCalledWith({ + params: { model_type: ModelTypeEnum.speech2text }, + }) + expect(getModelList).not.toHaveBeenCalledWith({ params: { model_type: ModelTypeEnum.tts } }) await user.click(screen.getByRole('button', { name: /system model settings/i })) await waitFor(() => { - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.textEmbedding } }, - }), - enabled: true, - }), - ) - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.rerank } }, - }), - enabled: true, - }), - ) - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.speech2text } }, - }), - enabled: true, - }), - ) - expect(mockModelListQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({ - input: { params: { model_type: ModelTypeEnum.tts } }, - }), - enabled: true, - }), - ) + expect(getModelList).toHaveBeenCalledWith({ + params: { model_type: ModelTypeEnum.textEmbedding }, + }) + expect(getModelList).toHaveBeenCalledWith({ params: { model_type: ModelTypeEnum.rerank } }) + expect(getModelList).toHaveBeenCalledWith({ + params: { model_type: ModelTypeEnum.speech2text }, + }) + expect(getModelList).toHaveBeenCalledWith({ params: { model_type: ModelTypeEnum.tts } }) }) }) it('shows loading instead of empty model selectors while model lists load', async () => { const user = userEvent.setup() - mockModelListQuery.mockReturnValue({ data: [], isPending: true }) + getModelList.mockReturnValue(new Promise(() => {})) render() await user.click(screen.getByRole('button', { name: /system model settings/i })) @@ -328,9 +284,10 @@ describe('SystemModel', () => { expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() }) - const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' }) + const selectorButtons = await screen.findAllByRole('button', { name: 'Mock Model Selector' }) selectorButtons.forEach((button) => fireEvent.click(button)) + await waitFor(() => expect(screen.getByRole('button', { name: /save/i })).toBeEnabled()) fireEvent.click(screen.getByRole('button', { name: /save/i })) await waitFor(() => { @@ -351,6 +308,7 @@ describe('SystemModel', () => { expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() }) + await waitFor(() => expect(screen.getByRole('button', { name: /save/i })).toBeEnabled()) fireEvent.click(screen.getByRole('button', { name: /save/i })) await waitFor(() => { @@ -409,7 +367,7 @@ describe('SystemModel', () => { await user.click(screen.getByRole('button', { name: /system model settings/i })) await user.click(screen.getByRole('button', { name: 'Reset System Reasoning Model' })) - await user.click(screen.getAllByRole('button', { name: 'Mock Model Selector' })[0]!) + await user.click((await screen.findAllByRole('button', { name: 'Mock Model Selector' }))[0]!) expect(screen.getByRole('button', { name: 'Reset System Reasoning Model' })).toBeEnabled() await user.click(screen.getByRole('button', { name: 'Save' })) @@ -525,8 +483,3 @@ describe('SystemModel', () => { } }) }) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQuery: mockModelListQuery } -}) diff --git a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx index b395d15fc21c86..4822ef7055a21d 100644 --- a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx @@ -1,29 +1,29 @@ import type { Role } from '@/models/access-control' -import { screen, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { useRoleGroups } from '../hooks' import PermissionsPage from '../index' -const { mockCreateWorkspaceRoleHook, mockUpdateWorkspaceRoleHook } = vi.hoisted(() => ({ - mockCreateWorkspaceRoleHook: vi.fn(), - mockUpdateWorkspaceRoleHook: vi.fn(), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.rbac.roles.post': () => mockCreateWorkspaceRoleHook(), - 'workspaces.current.rbac.roles.byRoleId.put': () => mockUpdateWorkspaceRoleHook(), - }) -}) - const mocks = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], createWorkspaceRole: vi.fn(), updateWorkspaceRole: vi.fn(), })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.rbac.roles.post': mocks.createWorkspaceRole, + 'workspaces.current.rbac.roles.byRoleId.put': mocks.updateWorkspaceRole, + }), + } +}) + vi.mock('@/app/notifications', () => ({ toast: { success: vi.fn(), @@ -117,15 +117,6 @@ const role: Role = { role_tag: '', } -const mockMutation = (mock: ReturnType) => { - mock.mockImplementation((_payload, options) => { - options?.onSuccess?.() - return Promise.resolve() - }) - - return { mutateAsync: mock } -} - const renderPermissionsPage = () => { const containerRef = { current: document.createElement('div'), @@ -158,12 +149,8 @@ describe('PermissionsPage', () => { hasNextPage: false, error: null, } as ReturnType) - vi.mocked(mockCreateWorkspaceRoleHook).mockReturnValue( - mockMutation(mocks.createWorkspaceRole) as ReturnType, - ) - vi.mocked(mockUpdateWorkspaceRoleHook).mockReturnValue( - mockMutation(mocks.updateWorkspaceRole) as ReturnType, - ) + mocks.createWorkspaceRole.mockResolvedValue(role) + mocks.updateWorkspaceRole.mockResolvedValue(role) }) it('hides role creation without workspace role manage permission', () => { @@ -200,16 +187,14 @@ describe('PermissionsPage', () => { expect(screen.getByRole('dialog', { name: 'create role' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'submit role' })) - expect(mocks.createWorkspaceRole).toHaveBeenCalledWith( - { - body: { - name: 'create name', - description: 'create description', - permission_keys: ['workspace.member.manage'], - }, + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(mocks.createWorkspaceRole).toHaveBeenCalledWith({ + body: { + name: 'create name', + description: 'create description', + permission_keys: ['workspace.member.manage'], }, - expect.any(Object), - ) + }) }) it('updates the selected workspace role from the edit modal', async () => { @@ -221,16 +206,25 @@ describe('PermissionsPage', () => { expect(within(dialog).getByText('Custom manager')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'submit role' })) - expect(mocks.updateWorkspaceRole).toHaveBeenCalledWith( - { - params: { role_id: 'role-1' }, - body: { - name: 'edit name', - description: 'edit description', - permission_keys: ['workspace.member.manage'], - }, + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(mocks.updateWorkspaceRole).toHaveBeenCalledWith({ + params: { role_id: 'role-1' }, + body: { + name: 'edit name', + description: 'edit description', + permission_keys: ['workspace.member.manage'], }, - expect.any(Object), - ) + }) + }) + it('keeps the role modal open until creation succeeds', async () => { + const response = createDeferred() + mocks.createWorkspaceRole.mockReturnValue(response.promise) + mocks.workspacePermissionKeys = ['workspace.role.manage'] + renderPermissionsPage() + await userEvent.click(screen.getByRole('button', { name: 'permission.role.addRole' })) + await userEvent.click(screen.getByRole('button', { name: 'submit role' })) + expect(screen.getByRole('dialog')).toBeInTheDocument() + await act(async () => response.resolve(role)) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) }) diff --git a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx index 3bc03870d67a6a..ebcbbe33a1479a 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx @@ -1,35 +1,28 @@ import type { Role } from '@/models/access-control' -import { fireEvent, screen, within } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import RowMenu from '../row-menu' const mockWorkspacePermissionKeys = vi.hoisted(() => ({ value: ['workspace.role.manage'] as string[], })) -const mockCopyRole = vi.hoisted(() => - vi.fn((_variables, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }), -) -const mockDeleteRole = vi.hoisted(() => vi.fn()) -const membersOfRoleQueryMock = vi.hoisted(() => { - const response = { - data: [], - pagination: { - total_count: 3, - per_page: 1, - current_page: 1, - total_pages: 3, - }, - } - +const { mockCopyRole, mockDeleteRole, getMembersOfRole } = vi.hoisted(() => ({ + mockCopyRole: vi.fn(), + mockDeleteRole: vi.fn(), + getMembersOfRole: vi.fn(), +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') return { - getMembersOfRole: vi.fn((_options?: unknown) => ({ - data: response, - isPending: false, - })), + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.rbac.roles.byRoleId.copy.post': mockCopyRole, + 'workspaces.current.rbac.roles.byRoleId.delete': mockDeleteRole, + 'workspaces.current.rbac.roles.byRoleId.members.get': getMembersOfRole, + }), } }) @@ -74,25 +67,15 @@ const openMenu = async () => { } } -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.rbac.roles.byRoleId.copy.post': () => ({ - mutate: mockCopyRole, - isPending: false, - }), - 'workspaces.current.rbac.roles.byRoleId.delete': () => ({ - mutateAsync: mockDeleteRole, - isPending: false, - }), - 'workspaces.current.rbac.roles.byRoleId.members.get': (options: unknown) => - membersOfRoleQueryMock.getMembersOfRole(options), - }) -}) - describe('RowMenu', () => { beforeEach(() => { vi.clearAllMocks() + mockCopyRole.mockResolvedValue(createRole()) + mockDeleteRole.mockResolvedValue({ result: 'success' }) + getMembersOfRole.mockResolvedValue({ + data: [], + pagination: { total_count: 3, per_page: 9999, current_page: 1, total_pages: 1 }, + }) document.body .querySelectorAll('[role="menu"], [role="menuitem"]') .forEach((element) => element.remove()) @@ -226,31 +209,24 @@ describe('RowMenu', () => { within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), ) - const dialog = screen.getByRole('alertdialog') + const dialog = await screen.findByRole('alertdialog') expect(dialog).toHaveTextContent('permission.role.copyMembersTitle') expect(dialog).toHaveTextContent( 'permission.role.copyMembersDescription:{"name":"Editor","count":3}', ) - expect(membersOfRoleQueryMock.getMembersOfRole).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: [ - expect.any(Array), - expect.objectContaining({ - input: { params: { role_id: role.id }, query: { page: 1, limit: 9999 } }, - }), - ], - }), - ) + expect(getMembersOfRole).toHaveBeenCalledWith({ + params: { role_id: role.id }, + query: { page: 1, limit: 9999 }, + }) await user.click(within(dialog).getByRole('button', { name: 'common.operation.skip' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) expect(mockCopyRole).toHaveBeenCalledTimes(1) - expect(mockCopyRole).toHaveBeenCalledWith( - { params: { role_id: role.id }, body: { copy_member: false } }, - expect.objectContaining({ - onSuccess: expect.any(Function), - }), - ) + expect(mockCopyRole).toHaveBeenCalledWith({ + params: { role_id: role.id }, + body: { copy_member: false }, + }) }) it('should copy member assignments when confirming duplicate with copy', async () => { @@ -266,17 +242,16 @@ describe('RowMenu', () => { await user.click( within(menu).getByRole('menuitem', { name: 'permission.common.duplicateAction' }), ) - const dialog = screen.getByRole('alertdialog') + const dialog = await screen.findByRole('alertdialog') await user.click(within(dialog).getByRole('button', { name: 'common.operation.copy' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) expect(mockCopyRole).toHaveBeenCalledTimes(1) - expect(mockCopyRole).toHaveBeenCalledWith( - { params: { role_id: role.id }, body: { copy_member: true } }, - expect.objectContaining({ - onSuccess: expect.any(Function), - }), - ) + expect(mockCopyRole).toHaveBeenCalledWith({ + params: { role_id: role.id }, + body: { copy_member: true }, + }) }) it('should ignore role management actions without manage permission', async () => { diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx index baa26840f4fdb3..67673c3dad5bbd 100644 --- a/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx @@ -39,13 +39,6 @@ vi.mock('@/service/console/browser', () => ({ }, })) -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - } -}) - vi.mock('@/context/modal-context', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 3a54d1a34dc74d..eba0fd81e6ee78 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -392,15 +392,6 @@ vi.mock('@/config', async (importOriginal) => { const mockPush = vi.fn() -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: (...args: Parameters) => - args[0] === 'pricing' ? actual.useQueryState(...args) : [null, mockSetSettingsDestination], - } -}) let mockPathname = '/apps' let mockInstalledApps: InstalledAppResponse[] = [] let mockInstalledAppsPending = false @@ -1305,7 +1296,11 @@ describe('MainNav', () => { expect( screen.getByRole('link', { name: /navigation\.mainNav\.workspace\.credits|7,500 credits/ }), ).toHaveAttribute('href', '/integrations/model-provider') - expect(mockSetSettingsDestination).not.toHaveBeenCalledWith('provider') + expect( + onPricingUrlUpdate.mock.calls.some( + ([event]) => event.searchParams.get('settings') === 'provider', + ), + ).toBe(false) fireEvent.click(screen.getByText('billing.upgradeBtn.plain')) await waitFor(() => @@ -1314,11 +1309,19 @@ describe('MainNav', () => { fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) fireEvent.click(await screen.findByText('navigation.mainNav.workspace.settings')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING) + await waitFor(() => + expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe( + ACCOUNT_SETTING_TAB.BILLING, + ), + ) fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) fireEvent.click(await screen.findByText('navigation.mainNav.workspace.inviteMembers')) - expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MEMBERS) + await waitFor(() => + expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe( + ACCOUNT_SETTING_TAB.MEMBERS, + ), + ) fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) fireEvent.click(await screen.findByText('Evan Workspace')) @@ -1358,7 +1361,11 @@ describe('MainNav', () => { await waitFor(() => expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'), ) - expect(mockSetSettingsDestination).not.toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING) + expect( + onPricingUrlUpdate.mock.calls.some( + ([event]) => event.searchParams.get('settings') === ACCOUNT_SETTING_TAB.BILLING, + ), + ).toBe(false) }) it('limits invite members by member management permission', async () => { diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index 6eb85255dfdcfe..2693b482cba798 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -97,15 +97,6 @@ const workspaceMenuAccessibleName = new RegExp( `${currentWorkspaceValue.name}.*navigation\\.mainNav\\.workspace\\.openMenu`, ) -const mockSetSettingsDestination = vi.fn() -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: (...args: Parameters) => - args[0] === 'pricing' ? actual.useQueryState(...args) : [null, mockSetSettingsDestination], - } -}) let mockCurrentWorkspace: GetWorkspacesCurrentSummaryResponse | undefined = currentWorkspaceValue let mockWorkspaces: TenantListItemResponse[] = [] @@ -586,7 +577,11 @@ describe('WorkspaceCard', () => { await screen.findByRole('button', { name: 'navigation.mainNav.workspace.settings' }), ) - expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING) + await waitFor(() => + expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe( + ACCOUNT_SETTING_TAB.BILLING, + ), + ) }) it('opens members settings from workspace menu when billing is disabled', async () => { @@ -602,8 +597,16 @@ describe('WorkspaceCard', () => { await screen.findByRole('button', { name: 'navigation.mainNav.workspace.settings' }), ) - expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MEMBERS) - expect(mockSetSettingsDestination).not.toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING) + await waitFor(() => + expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe( + ACCOUNT_SETTING_TAB.MEMBERS, + ), + ) + expect( + onPricingUrlUpdate.mock.calls.some( + ([event]) => event.searchParams.get('settings') === ACCOUNT_SETTING_TAB.BILLING, + ), + ).toBe(false) }) it('switches workspace from the workspace switcher item', async () => { diff --git a/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts b/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts index a40b02307ea954..5ea35968c1d899 100644 --- a/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts +++ b/web/app/components/plugins/__tests__/use-plugins-with-latest-version.spec.ts @@ -1,31 +1,19 @@ import type { PluginDetail } from '../types' -import { useQuery } from '@tanstack/react-query' -import { renderHook } from '@testing-library/react' -import { consoleQuery } from '@/service/console' +import { waitFor } from '@testing-library/react' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { usePluginsWithLatestVersion } from '../hooks' import { PluginSource } from '../types' - -vi.mock('@tanstack/react-query', () => ({ - useQuery: vi.fn(), -})) - -vi.mock('@/service/console', () => ({ - consoleQuery: { - workspaces: { - current: { - plugin: { - list: { - latestVersions: { - post: { - queryOptions: vi.fn((options: unknown) => options), - }, - }, - }, - }, - }, - }, - }, -})) +const getVersions = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.plugin.list.latestVersions.post': getVersions, + }), + } +}) const createPlugin = (overrides: Partial = {}): PluginDetail => ({ id: 'plugin-1', @@ -53,7 +41,7 @@ const createPlugin = (overrides: Partial = {}): PluginDetail => ({ describe('usePluginsWithLatestVersion', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useQuery).mockReturnValue({ data: undefined } as never) + getVersions.mockReset().mockImplementation(() => new Promise(() => {})) }) it('should disable latest-version querying when there are no marketplace plugins', () => { @@ -61,12 +49,7 @@ describe('usePluginsWithLatestVersion', () => { const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) - expect( - consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions, - ).toHaveBeenCalledWith({ - input: { body: { plugin_ids: [] } }, - enabled: false, - }) + expect(getVersions).not.toHaveBeenCalled() expect(result.current).toEqual(plugins) }) @@ -78,18 +61,17 @@ describe('usePluginsWithLatestVersion', () => { expect(result.current).toEqual(plugins) }) - it('should keep plugins unchanged when a plugin has no matching latest version', () => { + it('should keep plugins unchanged when a plugin has no matching latest version', async () => { const plugins = [createPlugin()] - vi.mocked(useQuery).mockReturnValue({ - data: { versions: {} }, - } as never) + getVersions.mockResolvedValue({ versions: {} }) - const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) + const { result, queryClient } = renderHook(() => usePluginsWithLatestVersion(plugins)) + await waitFor(() => expect(queryClient.isFetching()).toBe(0)) expect(result.current).toEqual(plugins) }) - it('should merge latest version fields for marketplace plugins with version data', () => { + it('should merge latest version fields for marketplace plugins with version data', async () => { const plugins = [ createPlugin(), createPlugin({ @@ -101,28 +83,22 @@ describe('usePluginsWithLatestVersion', () => { source: PluginSource.github, }), ] - vi.mocked(useQuery).mockReturnValue({ - data: { - versions: { - 'plugin-1': { - version: '1.1.0', - unique_identifier: 'plugin-1@1.1.0', - status: 'deleted', - deprecated_reason: 'replaced', - alternative_plugin_id: 'plugin-3', - }, + getVersions.mockResolvedValue({ + versions: { + 'plugin-1': { + version: '1.1.0', + unique_identifier: 'plugin-1@1.1.0', + status: 'deleted', + deprecated_reason: 'replaced', + alternative_plugin_id: 'plugin-3', }, }, - } as never) + }) const { result } = renderHook(() => usePluginsWithLatestVersion(plugins)) - expect( - consoleQuery.workspaces.current.plugin.list.latestVersions.post.queryOptions, - ).toHaveBeenCalledWith({ - input: { body: { plugin_ids: ['plugin-1'] } }, - enabled: true, - }) + await waitFor(() => expect(result.current[0]?.latest_version).toBe('1.1.0')) + expect(getVersions).toHaveBeenCalledWith({ body: { plugin_ids: ['plugin-1'] } }) expect(result.current).toEqual([ expect.objectContaining({ plugin_id: 'plugin-1', diff --git a/web/app/components/plugins/card/__tests__/index.spec.tsx b/web/app/components/plugins/card/__tests__/index.spec.tsx index b505a6d070db11..735626e8262e40 100644 --- a/web/app/components/plugins/card/__tests__/index.spec.tsx +++ b/web/app/components/plugins/card/__tests__/index.spec.tsx @@ -1,18 +1,32 @@ import type { CardPayload } from '../index' import { render } from '@testing-library/react' -import { useAtomValue } from 'jotai' import { describe, expect, it, vi } from 'vite-plus/test' import { MARKETPLACE_API_PREFIX } from '@/config' import { PluginCategoryEnum } from '../../types' import Card from '../index' -vi.mock('jotai', () => ({ - useAtomValue: vi.fn(), -})) +const readWorkspaceQuery = vi.hoisted(() => + vi.fn(() => { + throw new Error('Independent Marketplace must not access the Dify workspace service') + }), +) -vi.mock('@/context/workspace-state', () => ({ - currentWorkspaceIdAtom: Symbol('currentWorkspaceIdAtom'), -})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + consoleQuery: { + ...actual.consoleQuery, + workspaces: { + ...actual.consoleQuery.workspaces, + current: { + ...actual.consoleQuery.workspaces.current, + summary: { get: { queryOptions: readWorkspaceQuery } }, + }, + }, + }, + } +}) vi.mock('#i18n', () => ({ useTranslation: () => ({ @@ -82,10 +96,6 @@ const marketplacePlugin = { describe('Plugin card workspace boundary', () => { it('renders Marketplace variant icons without reading Dify workspace state', () => { - vi.mocked(useAtomValue).mockImplementation(() => { - throw new Error('Dify workspace state must not be read') - }) - const payloadWithoutSource = { ...marketplacePlugin, from: undefined, @@ -96,7 +106,7 @@ describe('Plugin card workspace boundary', () => { 'src', `${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`, ) - expect(useAtomValue).not.toHaveBeenCalled() + expect(readWorkspaceQuery).not.toHaveBeenCalled() }) it('labels the marketplace author as by, not the tools.author key fallback', () => { diff --git a/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts index f27ff67e83f8af..e950c7ca5f55ed 100644 --- a/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/utils.spec.ts @@ -1,5 +1,5 @@ import type { PluginDeclaration, PluginManifestInMarket } from '../../types' -import { describe, expect, it, vi } from 'vite-plus/test' +import { describe, expect, it } from 'vite-plus/test' import { PluginCategoryEnum } from '../../types' import { convertRepoToUrl, @@ -10,15 +10,6 @@ import { toPluginText, } from '../utils' -// Mock es-toolkit/compat -vi.mock('es-toolkit/compat', () => ({ - isEmpty: (obj: unknown) => { - if (obj === null || obj === undefined) return true - if (typeof obj === 'object') return Object.keys(obj).length === 0 - return false - }, -})) - describe('pluginManifestToCardPluginProps', () => { const createMockPluginDeclaration = ( overrides?: Partial, diff --git a/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx index 88e14e954677b8..9c79b96a8c6900 100644 --- a/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx +++ b/web/app/components/plugins/install-plugin/base/__tests__/installed.spec.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { createPluginDeclarationFixture } from '@/test/fixtures/plugin' import { PluginCategoryEnum } from '../../../types' vi.mock('../../../card', () => ({ @@ -19,11 +20,6 @@ vi.mock('../../../card', () => ({ ), })) -vi.mock('../../utils', () => ({ - pluginManifestInMarketToPluginProps: (p: unknown) => p, - pluginManifestToCardPluginProps: (p: unknown) => p, -})) - describe('Installed', () => { let Installed: (typeof import('../installed'))['default'] @@ -52,7 +48,7 @@ describe('Installed', () => { }) it('should render card with payload', () => { - const payload = { version: '1.0.0', name: 'test-plugin' } as never + const payload = createPluginDeclarationFixture() render() const card = screen.getByTestId('card') @@ -61,7 +57,7 @@ describe('Installed', () => { }) it('should render card as failed when isFailed', () => { - const payload = { version: '1.0.0', name: 'test-plugin' } as never + const payload = createPluginDeclarationFixture() render() const card = screen.getByTestId('card') @@ -79,11 +75,11 @@ describe('Installed', () => { it('should keep close action when installed category matches integrations context', () => { const mockOnCancel = vi.fn() - const payload = { + const payload = createPluginDeclarationFixture({ category: PluginCategoryEnum.extension, name: 'test-plugin', version: '1.0.0', - } as never + }) render( { it('should navigate to the installed category when integrations context differs', () => { const mockOnCancel = vi.fn() - const payload = { + const payload = createPluginDeclarationFixture({ category: PluginCategoryEnum.extension, name: 'test-plugin', version: '1.0.0', - } as never + }) render( { }) it('should show version badge in card', () => { - const payload = { version: '1.0.0', name: 'test-plugin' } as never + const payload = createPluginDeclarationFixture() render() expect(screen.getByText('1.0.0')).toBeInTheDocument() diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx index b9aa16939c1f84..88911b77251374 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-check-installed.spec.tsx @@ -1,87 +1,63 @@ -import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { act, waitFor } from '@testing-library/react' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' +import { createPluginEntity } from '@/test/fixtures/plugin' import useCheckInstalled from '../use-check-installed' -const mockPlugins = [ - { - plugin_id: 'plugin-1', - id: 'installed-1', - declaration: { version: '1.0.0' }, - plugin_unique_identifier: 'org/plugin-1', - }, - { - plugin_id: 'plugin-2', - id: 'installed-2', - declaration: { version: '2.0.0' }, - plugin_unique_identifier: 'org/plugin-2', - }, -] - -vi.mock('@/service/use-plugins', () => ({ - useCheckInstalled: ({ pluginIds, enabled }: { pluginIds: string[]; enabled: boolean }) => ({ - data: enabled && pluginIds.length > 0 ? { plugins: mockPlugins } : undefined, - isLoading: false, - error: null, - }), -})) - -describe('useCheckInstalled', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return installed info when enabled and has plugin IDs', () => { - const { result } = renderHook(() => - useCheckInstalled({ - pluginIds: ['plugin-1', 'plugin-2'], - enabled: true, - }), - ) - - expect(result.current.installedInfo).toBeDefined() - expect(result.current.installedInfo?.['plugin-1']).toEqual({ - installedId: 'installed-1', - installedVersion: '1.0.0', - uniqueIdentifier: 'org/plugin-1', - }) - expect(result.current.installedInfo?.['plugin-2']).toEqual({ - installedId: 'installed-2', - installedVersion: '2.0.0', - uniqueIdentifier: 'org/plugin-2', - }) - }) - - it('should return undefined installedInfo when disabled', () => { - const { result } = renderHook(() => - useCheckInstalled({ - pluginIds: ['plugin-1'], - enabled: false, - }), - ) - - expect(result.current.installedInfo).toBeUndefined() - }) +const request = vi.hoisted(() => vi.fn()) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.plugin.list.installations.ids.post': request, + }), + } +}) - it('should return undefined installedInfo with empty plugin IDs', () => { - const { result } = renderHook(() => - useCheckInstalled({ - pluginIds: [], - enabled: true, - }), - ) +beforeEach(() => { + request.mockResolvedValue({ plugins: [] }) +}) - expect(result.current.installedInfo).toBeUndefined() +it('waits for installed plugins and maps their server identities and versions', async () => { + const pending = createDeferred<{ plugins: ReturnType[] }>() + request.mockReturnValue(pending.promise) + const plugin = createPluginEntity() + const { result } = renderHook(() => + useCheckInstalled({ pluginIds: [plugin.plugin_id], enabled: true }), + ) + expect(result.current.isLoading).toBe(true) + expect(result.current.installedInfo).toBeUndefined() + await act(async () => pending.resolve({ plugins: [plugin] })) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(request).toHaveBeenCalledWith({ body: { plugin_ids: [plugin.plugin_id] } }) + expect(result.current.installedInfo).toEqual({ + [plugin.plugin_id]: { + installedId: plugin.id, + installedVersion: plugin.declaration.version, + uniqueIdentifier: plugin.plugin_unique_identifier, + }, }) +}) - it('should return isLoading and error states', () => { - const { result } = renderHook(() => - useCheckInstalled({ - pluginIds: ['plugin-1'], - enabled: true, - }), - ) +it('starts the installation lookup only after the caller enables it', async () => { + const { result, rerender } = renderHook( + ({ enabled }) => useCheckInstalled({ pluginIds: ['plugin-1'], enabled }), + { initialProps: { enabled: false } }, + ) + expect(request).not.toHaveBeenCalled() + expect(result.current.installedInfo).toBeUndefined() + rerender({ enabled: true }) + await waitFor(() => expect(result.current.installedInfo).toEqual({})) + expect(request).toHaveBeenCalledOnce() +}) - expect(result.current.isLoading).toBe(false) - expect(result.current.error).toBeNull() - }) +it('exposes lookup failures without manufacturing an installed result', async () => { + const error = new Error('lookup failed') + request.mockRejectedValue(error) + const { result } = renderHook(() => useCheckInstalled({ pluginIds: ['plugin-1'], enabled: true })) + await waitFor(() => expect(result.current.error).toBe(error)) + expect(result.current.installedInfo).toBeUndefined() + expect(result.current.isLoading).toBe(false) }) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx index 6e8e41a8b8f93c..ee30187c061c72 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx @@ -1,11 +1,11 @@ -import type { GitHubItemAndMarketPlaceDependency, Plugin } from '../../../../types' +import type { GitHubItemAndMarketPlaceDependency } from '../../../../types' import type { VersionProps } from '@/app/components/plugins/types' import { render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { createPluginEntity } from '@/test/fixtures/plugin' import GithubItem from '../github-item' const mockUseUploadGitHub = vi.fn() -const mockPluginDeclarationToCardPluginProps = vi.fn() const mockLoadedItem = vi.fn() vi.mock('@/service/use-plugins', () => ({ @@ -13,11 +13,6 @@ vi.mock('@/service/use-plugins', () => ({ mockUseUploadGitHub(params), })) -vi.mock('../../../utils', () => ({ - pluginDeclarationToCardPluginProps: (manifest: unknown, uniqueIdentifier: string) => - mockPluginDeclarationToCardPluginProps(manifest, uniqueIdentifier), -})) - vi.mock('../../../base/loading', () => ({ default: () =>
loading
, })) @@ -79,16 +74,20 @@ describe('GithubItem', () => { org: 'dify', icon: 'icon.png', version: '1.0.0', - } as Plugin + } mockUseUploadGitHub.mockReturnValue({ data: { - manifest: { name: 'manifest' }, + manifest: { + ...createPluginEntity().declaration, + name: 'Plugin One', + author: 'dify', + icon: 'icon.png', + }, unique_identifier: 'plugin-1', }, error: null, }) - mockPluginDeclarationToCardPluginProps.mockReturnValue(payload) render( { ) await waitFor(() => { - expect(mockPluginDeclarationToCardPluginProps).toHaveBeenCalledWith( - { name: 'manifest' }, - 'plugin-1', + expect(onFetchedPayload).toHaveBeenCalledWith( + expect.objectContaining({ ...payload, label: { en_US: 'Plugin label' }, tags: [] }), ) - expect(onFetchedPayload).toHaveBeenCalledWith(payload) expect(screen.getByTestId('loaded-item')).toBeInTheDocument() }) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx index 10e5934b791b90..935e97387cf1f6 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install.spec.tsx @@ -1,836 +1,183 @@ -import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../../types' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import type { Dependency, Plugin } from '../../../../types' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' +import { PermissionType, PluginCategoryEnum, TaskStatus } from '../../../../types' import Install from '../install' -// ==================== Mock Setup ==================== - -// Mock useInstallOrUpdate and usePluginTaskList -const mockInstallOrUpdate = vi.fn() -const mockHandleRefetch = vi.fn() -let mockInstallResponse: 'success' | 'failed' | 'running' = 'success' - -vi.mock('@/service/use-plugins', () => ({ - useInstallOrUpdate: (options: { onSuccess: (res: InstallStatusResponse[]) => void }) => { - mockInstallOrUpdate.mockImplementation((params: { payload: Dependency[] }) => { - // Call onSuccess with mock response based on mockInstallResponse - const getStatus = () => { - if (mockInstallResponse === 'success') return TaskStatus.success - if (mockInstallResponse === 'failed') return TaskStatus.failed - return TaskStatus.running - } - const mockResponse: InstallStatusResponse[] = params.payload.map(() => ({ - status: getStatus(), - taskId: 'mock-task-id', - uniqueIdentifier: 'mock-uid', - })) - options.onSuccess(mockResponse) - }) - return { - mutate: mockInstallOrUpdate, - isPending: false, - } - }, - usePluginTaskList: () => ({ - handleRefetch: mockHandleRefetch, - }), +const { marketplace, checkTask, refresh, installRequest } = vi.hoisted(() => ({ + marketplace: vi.fn(), + checkTask: vi.fn(), + refresh: vi.fn(), + installRequest: vi.fn(), })) - -// Mock checkTaskStatus -const mockCheck = vi.fn() -const mockStop = vi.fn() -vi.mock('../../../base/check-task-status', () => ({ - default: () => ({ - check: mockCheck, - stop: mockStop, - }), +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + postMarketplace: marketplace, })) - -// Mock useRefreshPluginList -const mockRefreshPluginList = vi.fn() +vi.mock('@/service/plugins', async (importOriginal) => ({ + ...(await importOriginal()), + checkTaskStatus: checkTask, +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses, withConsoleClientResponses } = + await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.permission.fetch.get': async () => ({ + install_permission: PermissionType.everyone, + debug_permission: PermissionType.everyone, + }), + 'workspaces.current.plugin.tasks.get': async () => ({ tasks: [] }), + 'workspaces.current.plugin.install.marketplace.post': installRequest, + }), + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }), + }), + } +}) +// Refreshing unrelated model/tool catalogs is independently covered by use-refresh-plugin-list. vi.mock('../../../hooks/use-refresh-plugin-list', () => ({ - default: () => ({ - refreshPluginList: mockRefreshPluginList, - }), + default: () => ({ refreshPluginList: refresh }), })) -// Mock useCanInstallPluginFromMarketplace -vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ - useCanInstallPluginFromMarketplace: () => ({ canInstallPluginFromMarketplace: true }), +const plugins: Plugin[] = ['finished', 'failed', 'running'].map((name) => ({ + type: 'plugin', + org: 'test', + name, + plugin_id: `test/${name}`, + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: `test/${name}:1.0.0`, + icon: '', + verified: true, + label: { en_US: name }, + brief: { en_US: 'Test plugin' }, + description: { en_US: 'Test plugin' }, + introduction: '', + repository: '', + category: PluginCategoryEnum.tool, + install_count: 1, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +})) +const dependencies: Dependency[] = plugins.map((plugin) => ({ + type: 'marketplace', + value: { marketplace_plugin_unique_identifier: plugin.latest_package_identifier }, })) -// Mock InstallMulti component with forwardRef support -vi.mock('../install-multi', async () => { - const React = await import('react') - - const createPlugin = (index: number) => ({ - type: 'plugin', - org: 'test-org', - name: `Test Plugin ${index}`, - plugin_id: `test-plugin-${index}`, - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: `test-pkg-${index}`, - icon: 'icon.png', - verified: true, - label: { 'en-US': `Test Plugin ${index}` }, - brief: { 'en-US': 'Brief' }, - description: { 'en-US': 'Description' }, - introduction: 'Intro', - repository: 'https://github.com/test/plugin', - category: 'tool', - install_count: 100, - endpoint: { settings: [] }, - tags: [], - badges: [], - verification: { authorized_category: 'community' }, - from: 'marketplace', - }) - - const MockInstallMulti = React.forwardRef( - ( - props: { - allPlugins: { length: number }[] - selectedPlugins: { plugin_id: string }[] - onSelect: (plugin: ReturnType, index: number, total: number) => void - onSelectAll: (plugins: ReturnType[], indexes: number[]) => void - onDeSelectAll: () => void - onLoadedAllPlugin: (info: Record) => void - }, - ref: React.ForwardedRef<{ selectAllPlugins: () => void; deSelectAllPlugins: () => void }>, - ) => { - const { - allPlugins, - selectedPlugins, - onSelect, - onSelectAll, - onDeSelectAll, - onLoadedAllPlugin, - } = props - - const allPluginsRef = React.useRef(allPlugins) - React.useEffect(() => { - allPluginsRef.current = allPlugins - }, [allPlugins]) - - // Expose ref methods - React.useImperativeHandle( - ref, - () => ({ - selectAllPlugins: () => { - const plugins = allPluginsRef.current.map((_, i) => createPlugin(i)) - const indexes = allPluginsRef.current.map((_, i) => i) - onSelectAll(plugins, indexes) - }, - deSelectAllPlugins: () => { - onDeSelectAll() - }, - }), - [onSelectAll, onDeSelectAll], - ) - - // Simulate loading completion when mounted - React.useEffect(() => { - const installedInfo = {} - onLoadedAllPlugin(installedInfo) - }, [onLoadedAllPlugin]) - - return ( -
- {allPlugins.length} - {selectedPlugins.length} - - - - - -
- ) - }, - ) - - return { default: MockInstallMulti } +beforeEach(() => { + marketplace.mockResolvedValue({ data: { list: plugins.map((plugin) => ({ plugin })) } }) }) - -// ==================== Test Utilities ==================== - -const createMockDependency = ( - type: 'marketplace' | 'github' | 'package' = 'marketplace', - index = 0, -): Dependency => { - if (type === 'marketplace') { - return { - type: 'marketplace', - value: { - marketplace_plugin_unique_identifier: `plugin-${index}-uid`, - }, - } as Dependency - } - if (type === 'github') { - return { - type: 'github', - value: { - repo: `test/plugin${index}`, - version: 'v1.0.0', - package: `plugin${index}.zip`, - }, - } as Dependency - } - return { - type: 'package', - value: { - unique_identifier: `package-plugin-${index}-uid`, - manifest: { - plugin_unique_identifier: `package-plugin-${index}-uid`, - version: '1.0.0', - author: 'test-author', - icon: 'icon.png', - name: `Package Plugin ${index}`, - category: PluginCategoryEnum.tool, - label: { 'en-US': `Package Plugin ${index}` }, - description: { 'en-US': 'Test package plugin' }, - created_at: '2024-01-01', - resource: {}, - plugins: [], - verified: true, - endpoint: { settings: [], endpoints: [] }, - model: null, - tags: [], - agent_strategy: null, - meta: { version: '1.0.0' }, - trigger: {}, +afterEach(() => vi.restoreAllMocks()) + +const renderInstall = () => { + const onInstalled = vi.fn() + const onStartToInstall = vi.fn() + renderWithConsoleQuery( + , + { + workspacePermissionKeys: ['plugin.install'], + systemFeatures: { + enable_marketplace: true, + rbac_enabled: true, + plugin_installation_permission: { + plugin_installation_scope: 'all', + restrict_to_marketplace_only: false, + }, }, }, - } as unknown as PackageDependency + ) + return { onInstalled, onStartToInstall } } -// ==================== Install Component Tests ==================== -describe('Install Component', () => { - const defaultProps = { - allPlugins: [createMockDependency('marketplace', 0), createMockDependency('github', 1)], - onStartToInstall: vi.fn(), - onInstalled: vi.fn(), - onCancel: vi.fn(), - isFromMarketPlace: true, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - // ==================== Rendering Tests ==================== - describe('Rendering', () => { - it('should render InstallMulti component with correct props', () => { - render() - - expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('2') - }) - - it('should make the plugin list scrollable inside the modal body', () => { - render() - - expect(screen.getByTestId('install-multi').parentElement).toHaveClass('overflow-y-auto') - }) - - it('should constrain the install step so the plugin list can scroll with many items', () => { - const { container } = render() - - expect(container.firstElementChild).toHaveClass('min-h-0', 'flex-1', 'overflow-hidden') - }) - - it('should show singular text when one plugin is selected', async () => { - render() - - // Select one plugin - await act(async () => { - fireEvent.click(screen.getByTestId('select-plugin-0')) - }) - - // Should show "1" in the ready to install message - expect(screen.getByText(/plugin\.installModal\.readyToInstallPackage/i)).toBeInTheDocument() - }) - - it('should show plural text when multiple plugins are selected', async () => { - render() - - // Select all plugins - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Should show "2" in the ready to install packages message - expect(screen.getByText(/plugin\.installModal\.readyToInstallPackages/i)).toBeInTheDocument() - }) - - it('should render action buttons when isHideButton is false', () => { - render() - - // Install button should be present - expect(screen.getByText(/plugin\.installModal\.install/i)).toBeInTheDocument() - }) - - it('should not render action buttons when isHideButton is true', () => { - render() - - // Install button should not be present - expect(screen.queryByText(/plugin\.installModal\.install/i)).not.toBeInTheDocument() - }) - - it('should show cancel button when canInstall is false', () => { - // Create a fresh component that hasn't loaded yet - vi.doMock('./install-multi', () => ({ - default: vi - .fn() - .mockImplementation(() =>
Loading...
), - })) - - // Since InstallMulti doesn't call onLoadedAllPlugin, canInstall stays false - // But we need to test this properly - for now just verify button states - render() - - // After loading, cancel button should not be shown - // Wait for the component to load - expect(screen.getByText(/plugin\.installModal\.install/i)).toBeInTheDocument() - }) - }) - - // ==================== Selection Tests ==================== - describe('Selection', () => { - it('should handle single plugin selection', async () => { - render() - - await act(async () => { - fireEvent.click(screen.getByTestId('select-plugin-0')) - }) - - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('1') - }) - - it('should handle select all plugins', async () => { - render() - - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2') - }) - - it('should handle deselect all plugins', async () => { - render() - - // First select all - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Then deselect all - await act(async () => { - fireEvent.click(screen.getByTestId('deselect-all-plugins')) - }) - - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('0') - }) - - it('should toggle select all checkbox state', async () => { - render() - - // After loading, handleLoadedAllPlugin triggers handleClickSelectAll which selects all - // So initially it shows deSelectAll - await waitFor(() => { - expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument() - }) - - // Click deselect all to deselect - await act(async () => { - fireEvent.click(screen.getByTestId('deselect-all-plugins')) - }) - - // Now should show selectAll since none are selected - await waitFor(() => { - expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument() - }) - }) - - it('should call deSelectAllPlugins when clicking selectAll checkbox while isSelectAll is true', async () => { - render() - - // After loading, handleLoadedAllPlugin is called which triggers handleClickSelectAll - // Since isSelectAll is initially false, it calls selectAllPlugins - // So all plugins are selected after loading - await waitFor(() => { - expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument() - }) - - // Click the checkbox container div (parent of the text) to trigger handleClickSelectAll - // The div has onClick={handleClickSelectAll} - // Since isSelectAll is true, it should call deSelectAllPlugins - const deSelectText = screen.getByText(/common\.operation\.deSelectAll/i) - const checkboxContainer = deSelectText.parentElement - await act(async () => { - if (checkboxContainer) fireEvent.click(checkboxContainer) - }) - - // Should now show selectAll again (deSelectAllPlugins was called) - await waitFor(() => { - expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument() - }) - }) - - it('should show indeterminate state when some plugins are selected', async () => { - const threePlugins = [ - createMockDependency('marketplace', 0), - createMockDependency('marketplace', 1), - createMockDependency('marketplace', 2), - ] - - render() - - // After loading, all 3 plugins are selected - await waitFor(() => { - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3') - }) - - // Deselect two plugins to get to indeterminate state (1 selected out of 3) - await act(async () => { - fireEvent.click(screen.getByTestId('toggle-plugin-0')) - }) - await act(async () => { - fireEvent.click(screen.getByTestId('toggle-plugin-0')) - }) - - // After toggle twice, we're back to all selected - // Let's instead click toggle once and check the checkbox component - // For now, verify the component handles partial selection - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3') - }) - }) - - // ==================== Install Action Tests ==================== - describe('Install Actions', () => { - it('should call onStartToInstall when install is clicked', async () => { - render() - - // Select a plugin first - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Click install button - const installButton = screen.getByText(/plugin\.installModal\.install/i) - await act(async () => { - fireEvent.click(installButton) - }) - - expect(defaultProps.onStartToInstall).toHaveBeenCalled() - }) - - it('should call installOrUpdate with correct payload', async () => { - render() - - // Select all plugins - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Click install - const installButton = screen.getByText(/plugin\.installModal\.install/i) - await act(async () => { - fireEvent.click(installButton) - }) - - expect(mockInstallOrUpdate).toHaveBeenCalled() - }) - - it('should call onInstalled when installation succeeds', async () => { - render() - - // Select all plugins - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Click install - const installButton = screen.getByText(/plugin\.installModal\.install/i) - await act(async () => { - fireEvent.click(installButton) - }) - - await waitFor(() => { - expect(defaultProps.onInstalled).toHaveBeenCalled() - }) - expect(defaultProps.onInstalled).toHaveBeenCalledWith( - expect.any(Array), - expect.any(Array), - expect.arrayContaining([ - expect.objectContaining({ - hasInstalled: false, - toInstallVersion: '1.0.0', - }), - ]), - ) - }) - - it('should refresh plugin list on successful installation', async () => { - render() - - // Select all plugins - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Click install - const installButton = screen.getByText(/plugin\.installModal\.install/i) - await act(async () => { - fireEvent.click(installButton) - }) - - await waitFor(() => { - expect(mockRefreshPluginList).toHaveBeenCalled() - }) - }) - - it('should disable install button when no plugins are selected', async () => { - render() - - // Deselect all - await act(async () => { - fireEvent.click(screen.getByTestId('deselect-all-plugins')) - }) - - const installButton = screen.getByText(/plugin\.installModal\.install/i).closest('button') - expect(installButton).toBeDisabled() - }) - }) - - // ==================== Cancel Action Tests ==================== - describe('Cancel Actions', () => { - it('should call stop and onCancel when cancel is clicked', async () => { - // Need to test when canInstall is false - // For now, the cancel button appears only before loading completes - // After loading, it disappears - - render() - - // The cancel button should not be visible after loading - // This is the expected behavior based on the component logic - await waitFor(() => { - expect(screen.queryByText(/common\.operation\.cancel/i)).not.toBeInTheDocument() - }) - }) - - it('should trigger handleCancel when cancel button is visible and clicked', async () => { - // Override the mock to NOT call onLoadedAllPlugin immediately - // This keeps canInstall = false so the cancel button is visible - vi.doMock('./install-multi', () => ({ - default: vi - .fn() - .mockImplementation(() =>
Loading...
), - })) - - // For this test, we just verify the cancel behavior - // The actual cancel button appears when canInstall is false - render() - - // Initially before loading completes, cancel should be visible - // After loading completes in our mock, it disappears - expect(document.body).toBeInTheDocument() - }) - }) - - // ==================== Edge Cases ==================== - describe('Edge Cases', () => { - it('should handle empty plugins array', () => { - render() - - expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('0') - }) - - it('should handle single plugin', () => { - render() - - expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('1') - }) - - it('should handle mixed dependency types', () => { - const mixedPlugins = [ - createMockDependency('marketplace', 0), - createMockDependency('github', 1), - createMockDependency('package', 2), - ] - - render() - - expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('3') - }) - - it('should handle failed installation', async () => { - mockInstallResponse = 'failed' - - render() - - // Select all plugins - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Click install - const installButton = screen.getByText(/plugin\.installModal\.install/i) - await act(async () => { - fireEvent.click(installButton) - }) - - // onInstalled should still be called with failure status - await waitFor(() => { - expect(defaultProps.onInstalled).toHaveBeenCalled() - }) - - // Reset for other tests - mockInstallResponse = 'success' - }) - - it('should handle running status and check task', async () => { - mockInstallResponse = 'running' - mockCheck.mockResolvedValue({ status: TaskStatus.success }) - - render() - - // Select all plugins - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Click install - const installButton = screen.getByText(/plugin\.installModal\.install/i) - await act(async () => { - fireEvent.click(installButton) - }) - - await waitFor(() => { - expect(mockHandleRefetch).toHaveBeenCalled() - }) - - await waitFor(() => { - expect(mockCheck).toHaveBeenCalled() - }) - - // Reset for other tests - mockInstallResponse = 'success' - }) - - it('should handle mixed status (some success/failed, some running)', async () => { - // Override mock to return mixed statuses - const mixedMockInstallOrUpdate = vi.fn() - vi.doMock('@/service/use-plugins', () => ({ - useInstallOrUpdate: (options: { onSuccess: (res: InstallStatusResponse[]) => void }) => { - mixedMockInstallOrUpdate.mockImplementation((_params: { payload: Dependency[] }) => { - // Return mixed statuses: first one is success, second is running - const mockResponse: InstallStatusResponse[] = [ - { status: TaskStatus.success, taskId: 'task-1', uniqueIdentifier: 'uid-1' }, - { status: TaskStatus.running, taskId: 'task-2', uniqueIdentifier: 'uid-2' }, - ] - options.onSuccess(mockResponse) - }) - return { - mutate: mixedMockInstallOrUpdate, - isPending: false, - } +it('aggregates finished, failed and running installs and polls only the unfinished task', async () => { + const user = userEvent.setup() + const pending = createDeferred<{ all_installed: boolean; task_id: string }>() + const install = installRequest.mockImplementation( + async ({ body }: { body: { plugin_unique_identifiers: string[] } }) => { + const id = body.plugin_unique_identifiers[0] + if (id === plugins[1]!.latest_package_identifier) throw new Error('Installation rejected') + if (id === plugins[2]!.latest_package_identifier) return pending.promise + return { all_installed: true, task_id: '' } + }, + ) + checkTask.mockResolvedValue({ + task: { + plugins: [ + { + plugin_unique_identifier: plugins[2]!.latest_package_identifier, + status: TaskStatus.success, }, - usePluginTaskList: () => ({ - handleRefetch: mockHandleRefetch, - }), - })) - - // The actual test logic would need to trigger this scenario - // For now, we verify the component renders correctly - render() - - expect(screen.getByTestId('install-multi')).toBeInTheDocument() - }) - - it('should not refresh plugin list when all installations fail', async () => { - mockInstallResponse = 'failed' - mockRefreshPluginList.mockClear() - - render() - - // Select all plugins - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Click install - const installButton = screen.getByText(/plugin\.installModal\.install/i) - await act(async () => { - fireEvent.click(installButton) - }) - - await waitFor(() => { - expect(defaultProps.onInstalled).toHaveBeenCalled() - }) - - // refreshPluginList should not be called when all fail - expect(mockRefreshPluginList).not.toHaveBeenCalled() - - // Reset for other tests - mockInstallResponse = 'success' - }) + ], + }, }) + const { onInstalled, onStartToInstall } = renderInstall() + const button = await screen.findByRole('button', { name: 'plugin.installModal.install' }) + await waitFor(() => expect(button).toBeEnabled()) + await user.click(button) + await screen.findByRole('button', { name: 'plugin.installModal.installing' }) + expect(onStartToInstall).toHaveBeenCalledOnce() + expect(onInstalled).not.toHaveBeenCalled() + expect(install).toHaveBeenCalledTimes(3) + pending.resolve({ all_installed: false, task_id: 'running-task' }) + await waitFor(() => expect(onInstalled).toHaveBeenCalledOnce()) + expect(checkTask).toHaveBeenCalledExactlyOnceWith('running-task') + expect(onInstalled).toHaveBeenCalledWith( + expect.arrayContaining(plugins.map(({ plugin_id }) => expect.objectContaining({ plugin_id }))), + [ + { success: true, isFromMarketPlace: true }, + { success: false, isFromMarketPlace: true }, + { success: true, isFromMarketPlace: true }, + ], + plugins.map(() => ({ + hasInstalled: false, + installedVersion: undefined, + toInstallVersion: '1.0.0', + })), + ) +}) - // ==================== Selection State Management ==================== - describe('Selection State Management', () => { - it('should set isSelectAll to false and isIndeterminate to false when all plugins are deselected', async () => { - render() - - // First select all - await act(async () => { - fireEvent.click(screen.getByTestId('select-all-plugins')) - }) - - // Then deselect using the mock button - await act(async () => { - fireEvent.click(screen.getByTestId('deselect-all-plugins')) - }) - - // Should show selectAll text (not deSelectAll) - await waitFor(() => { - expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument() - }) - }) - - it('should set isIndeterminate to true when some but not all plugins are selected', async () => { - const threePlugins = [ - createMockDependency('marketplace', 0), - createMockDependency('marketplace', 1), - createMockDependency('marketplace', 2), - ] - - render() - - // After loading, all 3 plugins are selected - await waitFor(() => { - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3') - }) - - // Deselect one plugin to get to indeterminate state (2 selected out of 3) - await act(async () => { - fireEvent.click(screen.getByTestId('toggle-plugin-0')) - }) - - // Component should be in indeterminate state (2 out of 3) - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2') - }) - - it('should toggle plugin selection correctly - deselect previously selected', async () => { - render() - - // After loading, all plugins (2) are selected via handleLoadedAllPlugin -> handleClickSelectAll - await waitFor(() => { - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2') - }) - - // Click toggle to deselect plugin 0 (toggle behavior) - await act(async () => { - fireEvent.click(screen.getByTestId('toggle-plugin-0')) - }) - - // Should have 1 selected now - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('1') - }) - - it('should set isSelectAll true when selecting last remaining plugin', async () => { - const twoPlugins = [ - createMockDependency('marketplace', 0), - createMockDependency('marketplace', 1), - ] - - render() - - // After loading, all plugins are selected - await waitFor(() => { - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2') - }) - - // Should show deSelectAll since all are selected - await waitFor(() => { - expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument() - }) - }) - - it('should handle selection when nextSelectedPlugins.length equals allPluginsLength', async () => { - const twoPlugins = [ - createMockDependency('marketplace', 0), - createMockDependency('marketplace', 1), - ] - - render() - - // After loading, all plugins are selected via handleLoadedAllPlugin -> handleClickSelectAll - // Wait for initial selection - await waitFor(() => { - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2') - }) - - // Both should be selected - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2') - }) - - it('should handle deselection to zero plugins', async () => { - render() - - // After loading, all plugins are selected via handleLoadedAllPlugin - await waitFor(() => { - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2') - }) - - // Use the deselect-all-plugins button to deselect all - await act(async () => { - fireEvent.click(screen.getByTestId('deselect-all-plugins')) - }) - - // Should have 0 selected - expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('0') +it('reports failed requests without polling or refreshing installed plugins', async () => { + const user = userEvent.setup() + installRequest.mockRejectedValue(new Error('Installation rejected')) + const { onInstalled } = renderInstall() + const button = await screen.findByRole('button', { name: 'plugin.installModal.install' }) + await waitFor(() => expect(button).toBeEnabled()) + await user.click(button) + await waitFor(() => expect(onInstalled).toHaveBeenCalledOnce()) + expect(onInstalled.mock.calls[0]![1]).toEqual( + plugins.map(() => ({ success: false, isFromMarketPlace: true })), + ) + expect(checkTask).not.toHaveBeenCalled() + expect(refresh).not.toHaveBeenCalled() +}) - // Should show selectAll - await waitFor(() => { - expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument() - }) - }) +it('installs only selected plugins and refreshes the catalog after immediate success', async () => { + const user = userEvent.setup() + const install = installRequest.mockResolvedValue({ all_installed: true, task_id: '' }) + const { onInstalled } = renderInstall() + const button = await screen.findByRole('button', { name: 'plugin.installModal.install' }) + await waitFor(() => expect(button).toBeEnabled()) + await user.click(screen.getByRole('checkbox', { name: 'common.operation.deSelectAll' })) + expect(button).toBeDisabled() + await user.click(screen.getByRole('checkbox', { name: 'finished' })) + await user.click(button) + await waitFor(() => expect(onInstalled).toHaveBeenCalledOnce()) + expect(install).toHaveBeenCalledExactlyOnceWith({ + body: { plugin_unique_identifiers: [plugins[0]!.latest_package_identifier] }, }) - - // ==================== Memoization Test ==================== + expect(refresh).toHaveBeenCalledWith(undefined, true) + expect(checkTask).not.toHaveBeenCalled() }) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx index 85b2b2d4b36653..acc8d2f8bd4453 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/__tests__/loaded.spec.tsx @@ -1,6 +1,7 @@ import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { PluginCategoryEnum, TaskStatus } from '../../../../types' import Loaded from '../loaded' @@ -17,8 +18,8 @@ vi.mock('@/service/plugins', () => ({ const mockInstallPackageFromGitHub = vi.fn() const mockHandleInstallTaskStart = vi.fn() -vi.mock('@/service/use-plugins', () => ({ - useInstallPackageFromGitHub: () => ({ mutateAsync: mockInstallPackageFromGitHub }), +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), usePluginTaskList: () => ({ handleInstallTaskStart: mockHandleInstallTaskStart }), })) @@ -114,6 +115,18 @@ const createUpdatePayload = (): UpdateFromGitHubPayload => ({ }, }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.install.github.post': (...args: unknown[]) => + mockInstallPackageFromGitHub(...args), + }), + } +}) + describe('Loaded', () => { const defaultProps = { updatePayload: undefined, @@ -254,10 +267,12 @@ describe('Loaded', () => { await waitFor(() => { expect(mockInstallPackageFromGitHub).toHaveBeenCalledWith({ - repoUrl: 'owner/repo', - selectedVersion: 'v1.0.0', - selectedPackage: 'plugin.zip', - uniqueIdentifier: 'test-unique-id', + body: { + repo: 'owner/repo', + version: 'v1.0.0', + package: 'plugin.zip', + plugin_unique_identifier: 'test-unique-id', + }, }) }) }) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx index 3d397ee800f760..68a557f1f4c516 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/__tests__/install.spec.tsx @@ -1,35 +1,12 @@ import type { ReactElement } from 'react' -import type { PluginDeclaration } from '../../../../types' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { render as renderWithConsoleState } from '@/test/console/render' -import { PluginCategoryEnum, TaskStatus } from '../../../../types' +import { createPluginDeclarationFixture } from '@/test/fixtures/plugin' +import { TaskStatus } from '../../../../types' import Install from '../install' -// Factory function for test data -const createMockManifest = (overrides: Partial = {}): PluginDeclaration => ({ - plugin_unique_identifier: 'test-plugin-uid', - version: '1.0.0', - author: 'test-author', - icon: 'test-icon.png', - name: 'Test Plugin', - category: PluginCategoryEnum.tool, - label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'], - description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'], - created_at: '2024-01-01T00:00:00Z', - resource: {}, - plugins: [], - verified: true, - endpoint: { settings: [], endpoints: [] }, - model: null, - tags: [], - agent_strategy: null, - meta: { version: '1.0.0', minimum_dify_version: '0.8.0' }, - trigger: {} as PluginDeclaration['trigger'], - ...overrides, -}) - // Mock external dependencies const mockUseCheckInstalled = vi.fn() vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ @@ -37,10 +14,8 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () })) const mockInstallPackageFromLocal = vi.fn() -vi.mock('@/service/use-plugins', () => ({ - useInstallPackageFromLocal: () => ({ - mutateAsync: mockInstallPackageFromLocal, - }), +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), usePluginTaskList: () => ({ handleInstallTaskStart: vi.fn(), }), @@ -106,18 +81,22 @@ vi.mock('../../../base/version', () => ({ ), })) -vi.mock('../../../utils', () => ({ - pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({ - name: manifest.name, - author: manifest.author, - version: manifest.version, - }), -})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.install.pkg.post': (...args: unknown[]) => + mockInstallPackageFromLocal(...args), + }), + } +}) describe('Install', () => { const defaultProps = { uniqueIdentifier: 'test-unique-identifier', - payload: createMockManifest(), + payload: createPluginDeclarationFixture(), onCancel: vi.fn(), onStartToInstall: vi.fn(), onInstalled: vi.fn(), @@ -201,7 +180,7 @@ describe('Install', () => { // ================================ describe('Version Display', () => { it('should display toInstallVersion from payload', () => { - const payload = createMockManifest({ version: '2.0.0' }) + const payload = createPluginDeclarationFixture({ version: '2.0.0' }) mockUseCheckInstalled.mockReturnValue({ installedInfo: null, isLoading: false, @@ -489,7 +468,7 @@ describe('Install', () => { describe('Dify Version Compatibility', () => { it('should not show warning when dify version is compatible', () => { mockConsoleState.langGeniusVersionInfo.current_version = '1.0.0' - const payload = createMockManifest({ + const payload = createPluginDeclarationFixture({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' }, }) @@ -500,7 +479,7 @@ describe('Install', () => { it('should show warning when dify version is incompatible', () => { mockConsoleState.langGeniusVersionInfo.current_version = '1.0.0' - const payload = createMockManifest({ + const payload = createPluginDeclarationFixture({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' }, }) @@ -511,7 +490,7 @@ describe('Install', () => { it('should be compatible when minimum_dify_version is undefined', () => { mockConsoleState.langGeniusVersionInfo.current_version = '1.0.0' - const payload = createMockManifest({ meta: { version: '1.0.0' } }) + const payload = createPluginDeclarationFixture({ meta: { version: '1.0.0' } }) render() @@ -520,7 +499,7 @@ describe('Install', () => { it('should be compatible when current_version is empty', () => { mockConsoleState.langGeniusVersionInfo.current_version = '' - const payload = createMockManifest({ + const payload = createPluginDeclarationFixture({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' }, }) @@ -532,7 +511,7 @@ describe('Install', () => { it('should be compatible when current_version is undefined', () => { mockConsoleState.langGeniusVersionInfo.current_version = undefined as unknown as string - const payload = createMockManifest({ + const payload = createPluginDeclarationFixture({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' }, }) diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx index 1dd2d4c08a71e1..89d100a4f5e360 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/__tests__/install.spec.tsx @@ -90,13 +90,8 @@ const render = (ui: ReactElement) => { } // Mock service hooks -vi.mock('@/service/use-plugins', () => ({ - useInstallPackageFromMarketPlace: () => ({ - mutateAsync: mockInstallPackageFromMarketPlace, - }), - useUpdatePackageFromMarketPlace: () => ({ - mutateAsync: mockUpdatePackageFromMarketPlace, - }), +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), usePluginDeclarationFromMarketPlace: () => ({ data: mockPluginDeclaration, }), @@ -166,13 +161,20 @@ vi.mock('../../../base/version', () => ({ })) // Mock utils -vi.mock('../../../utils', () => ({ - pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({ - name: payload.name, - icon: payload.icon, - category: payload.category, - }), -})) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.install.marketplace.post': (...args: unknown[]) => + mockInstallPackageFromMarketPlace(...args), + 'workspaces.current.plugin.upgrade.marketplace.post': (...args: unknown[]) => + mockUpdatePackageFromMarketPlace(...args), + }), + } +}) describe('Install Component (steps/install.tsx)', () => { const defaultProps = { @@ -411,7 +413,9 @@ describe('Install Component (steps/install.tsx)', () => { }) await waitFor(() => { - expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('test-unique-identifier') + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith({ + body: { plugin_unique_identifiers: ['test-unique-identifier'] }, + }) }) }) @@ -547,8 +551,10 @@ describe('Install Component (steps/install.tsx)', () => { await waitFor(() => { expect(mockUpdatePackageFromMarketPlace).toHaveBeenCalledWith({ - original_plugin_unique_identifier: 'old-unique-id', - new_plugin_unique_identifier: 'test-unique-identifier', + body: { + original_plugin_unique_identifier: 'old-unique-id', + new_plugin_unique_identifier: 'test-unique-identifier', + }, }) }) }) @@ -748,7 +754,9 @@ describe('Install Component (steps/install.tsx)', () => { }) await waitFor(() => { - expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('custom-id-123') + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith({ + body: { plugin_unique_identifiers: ['custom-id-123'] }, + }) }) }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx index 567f32f5b584b7..313a4ba0685705 100644 --- a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx @@ -11,8 +11,8 @@ vi.mock('@/service/base', () => ({ postMarketplace: vi.fn(), })) -vi.mock('../utils', () => ({ - getFormattedPlugin: (plugin: unknown) => plugin, +vi.mock('../utils', async (importOriginal) => ({ + ...(await importOriginal()), getMarketplaceCollectionsAndPlugins, getMarketplacePluginsByCollectionId, })) @@ -157,6 +157,8 @@ const createPlugin = (pluginID: string, category: PluginCategoryEnum) => plugin_id: pluginID, type: 'plugin', category, + name: pluginID, + org: 'dify', }) as Plugin const createInfiniteData = (plugin: Plugin, pageSize: number) => ({ diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts b/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts index b3bcfb00a8a577..0bcb2d9643919f 100644 --- a/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts @@ -1,19 +1,14 @@ import type { Plugin } from '@/app/components/plugins/types' -import { act, renderHook } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types' +import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { useSilentMarketplaceInstall } from '../use-silent-install' const mockInstallPackageFromMarketPlace = vi.fn() const mockRefreshPluginList = vi.fn() const mockCheckTaskStatus = vi.fn() -vi.mock('@/service/use-plugins', () => ({ - useInstallPackageFromMarketPlace: () => ({ - mutateAsync: mockInstallPackageFromMarketPlace, - }), -})) - vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ default: () => ({ refreshPluginList: mockRefreshPluginList }), })) @@ -34,6 +29,18 @@ const plugin = { category: PluginCategoryEnum.tool, } as Plugin +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.install.marketplace.post': (...args: unknown[]) => + mockInstallPackageFromMarketPlace(...args), + }), + } +}) + describe('useSilentMarketplaceInstall', () => { beforeEach(() => { vi.clearAllMocks() @@ -48,7 +55,9 @@ describe('useSilentMarketplaceInstall', () => { const { result } = renderHook(() => useSilentMarketplaceInstall()) await expect(result.current.install(plugin)).resolves.toEqual({ status: 'success' }) - expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith(plugin.latest_package_identifier) + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith({ + body: { plugin_unique_identifiers: [plugin.latest_package_identifier] }, + }) expect(mockCheckTaskStatus).not.toHaveBeenCalled() expect(mockRefreshPluginList).toHaveBeenCalledWith(plugin) }) @@ -99,7 +108,7 @@ describe('useSilentMarketplaceInstall', () => { const first = result.current.install(plugin) const second = result.current.install(plugin) - expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1)) await act(async () => { resolveInstall?.({ all_installed: true, task_id: 'task-4' }) diff --git a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx index f13b6364b7e9b2..799925aafcbc13 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx @@ -14,25 +14,13 @@ vi.mock('@/features/system-features/state', async () => { return createSystemFeaturesStateModuleMock(() => deploymentState) }) -const { debounceState, mockPluginSearch, mockTemplateSearch, mockCheckInstalled } = vi.hoisted( - () => ({ - debounceState: { useRealDebounce: false }, - mockCheckInstalled: vi.fn((_options: { pluginIds: string[]; enabled: boolean }) => ({ - installedInfo: {}, - })), - mockPluginSearch: vi.fn(), - mockTemplateSearch: vi.fn(), - }), -) - -vi.mock('foxact/use-debounced-value', async (importOriginal) => { - const original = await importOriginal() - - return { - useDebouncedValue: (value: string, wait: number) => - debounceState.useRealDebounce ? original.useDebouncedValue(value, wait) : value, - } -}) +const { mockPluginSearch, mockTemplateSearch, mockCheckInstalled } = vi.hoisted(() => ({ + mockCheckInstalled: vi.fn((_options: { pluginIds: string[]; enabled: boolean }) => ({ + installedInfo: {}, + })), + mockPluginSearch: vi.fn(), + mockTemplateSearch: vi.fn(), +})) vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') @@ -112,7 +100,6 @@ describe('EmbeddedMarketplaceSearch', () => { beforeEach(() => { deploymentState.deploymentEdition = 'CLOUD' vi.clearAllMocks() - debounceState.useRealDebounce = false queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx index b74506e8fcf1e4..4a604272152fc2 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx @@ -7,15 +7,6 @@ const { mockReplace } = vi.hoisted(() => ({ mockReplace: vi.fn(), })) -vi.mock('ahooks', async (importOriginal) => { - const original = await importOriginal() - - return { - ...original, - useDebounce: (value: T) => value, - } -}) - vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }), })) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx index 9e367eef8093ae..c7adcc33e4433a 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx @@ -18,10 +18,6 @@ const { mockTemplateSearch } = vi.hoisted(() => ({ mockTemplateSearch: vi.fn(), })) -vi.mock('foxact/use-debounced-value', () => ({ - useDebouncedValue: (value: T) => value, -})) - vi.mock('react-i18next', async (importOriginal) => { const original = await importOriginal() const { createReactI18nextMock } = await import('@/test/i18n-mock') diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx index c7161d3934521f..c36e0164d57215 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -10,24 +10,12 @@ import { MarketplaceSearchForm, } from '../marketplace-search-autocomplete' -const { debounceState, mockAssign, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ - // Most tests bypass the debounce for simplicity; the debounce-window test - // flips this on to exercise the real 300ms lag. - debounceState: { useRealDebounce: false }, +const { mockAssign, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ mockAssign: vi.fn(), mockPluginSearch: vi.fn(), mockTemplateSearch: vi.fn(), })) -vi.mock('foxact/use-debounced-value', async (importOriginal) => { - const original = await importOriginal() - - return { - useDebouncedValue: (value: string, wait: number) => - debounceState.useRealDebounce ? original.useDebouncedValue(value, wait) : value, - } -}) - vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') @@ -70,7 +58,6 @@ describe('MarketplaceSearchAutocomplete', () => { vi.clearAllMocks() mockAssign.mockReset() vi.spyOn(window.location, 'assign').mockImplementation(mockAssign) - debounceState.useRealDebounce = false queryClient = new QueryClient({ defaultOptions: { queries: { @@ -674,7 +661,6 @@ describe('MarketplaceSearchAutocomplete', () => { }) it('keeps the previous suggestions listed while the edited value is still debouncing', async () => { - debounceState.useRealDebounce = true mockPluginSearch.mockResolvedValue({ data: { plugins: [ diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx index 40b2386c6347bf..59025a155d7899 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-data-source-node.spec.tsx @@ -2,12 +2,6 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import AuthorizedInDataSourceNode from '../authorized-in-data-source-node' -vi.mock('@langgenius/dify-ui/status-dot', () => ({ - StatusDot: ({ status }: { status: string }) => ( - - ), -})) - describe('AuthorizedInDataSourceNode', () => { const mockOnJump = vi.fn() @@ -19,11 +13,6 @@ describe('AuthorizedInDataSourceNode', () => { cleanup() }) - it('renders with green indicator', () => { - render() - expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success') - }) - it('renders singular text for 1 authorization', () => { render() expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument() diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx index 8f1356f844cfca..e6e2542a9371d2 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -48,8 +48,6 @@ vi.mock('@/hooks/use-oauth', () => ({ vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => ({ data: { options: [] }, isLoading: false }), - useTriggerPluginDynamicOptionsInfo: () => ({ data: null, isLoading: false }), - useInvalidTriggerDynamicOptions: () => vi.fn(), })) // ==================== Test Utilities ==================== diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx index 93dc802bc8153b..44daa7df357500 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth-in-agent.spec.tsx @@ -64,8 +64,6 @@ vi.mock('@/hooks/use-oauth', () => ({ vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => ({ data: { options: [] }, isLoading: false }), - useTriggerPluginDynamicOptionsInfo: () => ({ data: null, isLoading: false }), - useInvalidTriggerDynamicOptions: () => vi.fn(), })) // ==================== Test Utilities ==================== diff --git a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx index af92ceb2d7cf92..5a2b0c0b98ca1b 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/plugin-auth.spec.tsx @@ -1,11 +1,11 @@ -import { cleanup, fireEvent, screen } from '@testing-library/react' +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { render } from '@/test/console/render' +import { render as renderWithoutUrlState } from '@/test/console/render' +import { createNuqsTestRenderer } from '@/test/nuqs-testing' import PluginAuth from '../plugin-auth' import { AuthCategory } from '../types' const mockUsePluginAuth = vi.fn() -const mockSetSettingsDestination = vi.fn() const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[], })) @@ -30,16 +30,14 @@ vi.mock('@/context/permission-state', async () => { })) }) -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } -}) - const defaultPayload = { category: AuthCategory.tool, provider: 'test-provider', } +const onUrlUpdate = vi.fn() +const render = createNuqsTestRenderer(renderWithoutUrlState, () => ({ onUrlUpdate })) + describe('PluginAuth', () => { beforeEach(() => { vi.clearAllMocks() @@ -139,7 +137,7 @@ describe('PluginAuth', () => { ).toBeInTheDocument() }) - it('opens members settings when permission hint action is clicked', () => { + it('opens members settings when permission hint action is clicked', async () => { mockConsoleState.workspacePermissionKeys = ['credential.use'] mockUsePluginAuth.mockReturnValue({ isAuthorized: false, @@ -153,7 +151,9 @@ describe('PluginAuth', () => { render() fireEvent.click(screen.getByRole('button', { name: 'plugin.auth.permissionHint.action' })) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('members') + await waitFor(() => + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('members'), + ) }) it('does not render permission hint for datasource authorization', () => { diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx index 7ca5277fe404b0..cadf1d98f46736 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx @@ -88,14 +88,6 @@ vi.mock('../oauth-client-settings', () => ({ }, })) -vi.mock('@/app/components/base/form/types', () => ({ - FormTypeEnum: { radio: 'radio' }, -})) - -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: unknown[]) => args.filter(Boolean).join(' '), -})) - const basePayload = { category: AuthCategory.tool, provider: 'test-provider', diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx index 2d0451a5644107..ecca193e121e9e 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -1,510 +1,122 @@ -import type { ApiKeyModalProps } from '../api-key-modal' import type { FormSchema } from '@/app/components/base/form/types' -import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useState } from 'react' +import { FormTypeEnum } from '@/app/components/base/form/types' import { renderWithAccountProfile as render } from '@/test/console/account-profile' +import { createDeferred } from '@/test/deferred' import { AuthCategory } from '../../types' +import ApiKeyModal from '../api-key-modal' -const { mockToast } = vi.hoisted(() => { - const mockNotify = vi.fn() - return { - mockToast: { - success: (message: string, options?: Record) => - mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record) => - mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record) => - mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record) => - mockNotify({ type: 'info', message, ...options }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - } -}) - -vi.mock('@/app/notifications', () => ({ - toast: mockToast, -})) - -const mockAddPluginCredential = vi.fn().mockResolvedValue({}) -const mockUpdatePluginCredential = vi.fn().mockResolvedValue({}) -const defaultCredentialSchemas = [ - { name: 'api_key', label: 'API Key', type: 'secret-input', required: true }, +const commands = vi.hoisted(() => ({ add: vi.fn(), update: vi.fn() })) +const schemas: FormSchema[] = [ + { name: 'api_key', label: 'API Key', type: FormTypeEnum.secretInput, required: true }, ] -type MockFormValues = { - isCheckValidated: boolean - values: Record -} - -const defaultFormValues: MockFormValues = { - isCheckValidated: true, - values: { __name__: 'My Key', api_key: 'sk-123' }, -} -let mockCredentialSchemas = defaultCredentialSchemas -let mockIsSchemaLoading = false -let mockFormValues = defaultFormValues -const mockAuthFormProps = vi.fn() - +// Credential routing has separate hook tests; this suite owns form validation and submitted values. vi.mock('../../hooks/use-credential', () => ({ - useAddPluginCredentialHook: () => ({ - mutateAsync: mockAddPluginCredential, - }), - useGetPluginCredentialSchemaHook: () => ({ - data: mockCredentialSchemas, - isLoading: mockIsSchemaLoading, - }), - useUpdatePluginCredentialHook: () => ({ - mutateAsync: mockUpdatePluginCredential, - }), -})) - -vi.mock('../../../readme-panel/entrance', () => ({ - ReadmeEntrance: () =>
, + useAddPluginCredentialHook: () => ({ mutateAsync: commands.add }), + useUpdatePluginCredentialHook: () => ({ mutateAsync: commands.update }), + useGetPluginCredentialSchemaHook: () => ({ data: schemas, isLoading: false }), })) - -vi.mock('@/app/components/base/encrypted-bottom', () => ({ - EncryptedBottom: () =>
, - EncryptedFooter: ({ children }: { children: React.ReactNode }) => <>{children}, -})) - -vi.mock('@/app/components/base/form/form-scenarios/auth', () => { - const MockAuthForm = ({ - ref, - ...props - }: { ref?: React.Ref } & Record) => { - mockAuthFormProps(props) - React.useImperativeHandle(ref, () => ({ - getFormValues: () => mockFormValues, - })) - return
- } - - return { - default: MockAuthForm, - } +const payload = { category: AuthCategory.tool, provider: 'test-provider' } +beforeEach(() => { + commands.add.mockResolvedValue({}) + commands.update.mockResolvedValue({}) }) -vi.mock('@/app/components/base/form/types', () => ({ - FormTypeEnum: { textInput: 'text-input' }, -})) +it('validates the API key and submits the entered name, secret and selected visibility', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(commands.add).not.toHaveBeenCalled() + await user.type(screen.getByLabelText(/API Key/), 'sk-123') + await user.type(screen.getByRole('textbox', { name: 'plugin.auth.authorizationName' }), 'My Key') + await user.click(screen.getByRole('button', { name: /permissionsAllMember/ })) + await user.click( + within(screen.getByRole('menu', { name: /auth.whoCanUse/ })).getByRole('menuitemradio', { + name: /permissionsOnlyMe/, + }), + ) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(commands.add).toHaveBeenCalledExactlyOnceWith({ + credentials: { api_key: 'sk-123' }, + name: 'My Key', + type: 'api-key', + visibility: 'only_me', + }) +}) -const basePayload = { - category: AuthCategory.tool, - provider: 'test-provider', -} +it('uses the existing credential identity and preserves an unchanged secret on edit', async () => { + const user = userEvent.setup() + render( + , + ) + const name = screen.getByRole('textbox', { name: 'plugin.auth.authorizationName' }) + expect(name).toHaveValue('Existing') + await user.clear(name) + await user.type(name, 'Renamed') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => + expect(commands.update).toHaveBeenCalledExactlyOnceWith({ + credentials: { api_key: '[__HIDDEN__]' }, + credential_id: 'cred-1', + name: 'Renamed', + }), + ) + expect(commands.add).not.toHaveBeenCalled() +}) -const PopoverModalHarness = ({ - ApiKeyModal, - onClose, - onPopoverClose, -}: { - ApiKeyModal: React.FC - onClose: () => void - onPopoverClose: () => void -}) => { - const [open, setOpen] = React.useState(true) +it('prevents duplicate submissions while the credential command is pending', async () => { + const user = userEvent.setup() + const request = createDeferred() + commands.add.mockReturnValue(request.promise) + const onClose = vi.fn() + render() + await user.type(screen.getByLabelText(/API Key/), 'secret') + const save = screen.getByRole('button', { name: 'common.operation.save' }) + await user.dblClick(save) + expect(save).toBeDisabled() + expect(commands.add).toHaveBeenCalledOnce() + expect(onClose).not.toHaveBeenCalled() + request.resolve({}) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) +}) - return ( - { - setOpen(nextOpen) - if (!nextOpen) onPopoverClose() - }} - > - Credentials} /> - -
+it('keeps the credentials popover open while editing inside its nested modal', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const onPopoverClose = vi.fn() + const Harness = () => { + const [open, setOpen] = useState(true) + return ( + { + setOpen(next) + if (!next) onPopoverClose() + }} + > + Credentials} /> + -
-
-
- ) -} - -const ControlledModalHarness = ({ - ApiKeyModal, - onClose, -}: { - ApiKeyModal: React.FC - onClose: () => void -}) => { - const [open, setOpen] = React.useState(true) - - return ( - <> -
{String(open)}
- - - ) -} - -describe('ApiKeyModal', () => { - let ApiKeyModal: React.FC - - beforeEach(async () => { - vi.clearAllMocks() - mockCredentialSchemas = defaultCredentialSchemas - mockIsSchemaLoading = false - mockFormValues = defaultFormValues - mockAddPluginCredential.mockResolvedValue({}) - mockUpdatePluginCredential.mockResolvedValue({}) - const mod = await import('../api-key-modal') - ApiKeyModal = mod.default - }) - - it('should render modal with correct title', () => { - render() - - expect(screen.getByRole('heading', { name: 'plugin.auth.useApiAuth' })).toBeInTheDocument() - }) - - it('should render auth form when data is loaded', () => { - render() - - expect(screen.getByTestId('auth-form')).toBeInTheDocument() - }) - - it('should prefer formSchemas prop and apply schema defaults', () => { - const customSchemas: FormSchema[] = [ - { - name: 'custom_api_key', - label: 'Custom API Key', - type: 'secret-input' as FormSchema['type'], - required: true, - default: 'default-key', - }, - ] - - render() - - expect(mockAuthFormProps).toHaveBeenCalledWith( - expect.objectContaining({ - formSchemas: expect.arrayContaining([expect.objectContaining({ name: 'custom_api_key' })]), - defaultValues: expect.objectContaining({ - custom_api_key: 'default-key', - }), - }), + + ) - }) - - it('should not render auth form when credential schema is empty', () => { - mockCredentialSchemas = [] - - render() - - expect(screen.queryByTestId('auth-form')).not.toBeInTheDocument() - }) - - it('should not submit when form ref is unavailable', () => { - mockCredentialSchemas = [] - - render() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockAddPluginCredential).not.toHaveBeenCalled() - }) - - it('should disable actions while loading credential schema', () => { - mockIsSchemaLoading = true - - render() - - expect(screen.queryByTestId('auth-form')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled() - }) - - it('should show remove button when editValues is provided', () => { - render() - - expect(screen.getByRole('button', { name: 'common.operation.remove' })).toBeInTheDocument() - }) - - it('should not show remove button in add mode', () => { - render() - - expect( - screen.queryByRole('button', { name: 'common.operation.remove' }), - ).not.toBeInTheDocument() - }) - - it('should call onClose when close button clicked', () => { - const mockOnClose = vi.fn() - render() - - fireEvent.click(screen.getByRole('button', { name: /Close|operation.close/ })) - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should call onClose after controlled cancel closes the modal', async () => { - const mockOnClose = vi.fn() - render() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - await waitFor(() => { - expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') - }) - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should close through controlled open state when cancel is clicked', async () => { - const mockOnClose = vi.fn() - render() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - await waitFor(() => { - expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') - }) - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should call addPluginCredential on confirm in add mode', async () => { - const mockOnClose = vi.fn() - const mockOnUpdate = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'api-key', - name: 'My Key', - visibility: 'all_team_members', - }), - ) - }) - }) - - it('selects credential visibility through the dropdown menu', async () => { - const user = userEvent.setup() - render() - - const trigger = screen.getByRole('button', { name: /permissionsAllMember/ }) - expect(trigger).toHaveAttribute('type', 'button') - await user.click(trigger) - const permissionMenu = screen.getByRole('menu', { name: /auth.whoCanUse/ }) - const permissionGroup = within(permissionMenu).getByRole('group', { - name: /auth.whoCanUse/, - }) - const allMembers = within(permissionGroup).getByRole('menuitemradio', { - name: /permissionsAllMember/, - }) - const onlyMe = within(permissionGroup).getByRole('menuitemradio', { - name: /permissionsOnlyMe/, - }) - expect(allMembers).toHaveAttribute('aria-checked', 'true') - - await user.click(onlyMe) - expect(screen.queryByRole('menu', { name: /auth.whoCanUse/ })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: /permissionsOnlyMe/ })).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalledWith( - expect.objectContaining({ visibility: 'only_me' }), - ) - }) - }) - - it('disables the credential visibility trigger with the modal', () => { - render() - - expect(screen.getByRole('button', { name: /permissionsAllMember/ })).toBeDisabled() - }) - - it('should use empty credential name when authorization name is blank in add mode', async () => { - mockFormValues = { isCheckValidated: true, values: { api_key: 'sk-123' } } - - render() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - }), - ) - }) - }) - - it('should not submit when form validation fails', () => { - mockFormValues = { isCheckValidated: false, values: {} } - - render() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockAddPluginCredential).not.toHaveBeenCalled() - expect(mockUpdatePluginCredential).not.toHaveBeenCalled() - }) - - it('should ignore repeated confirm while an action is in progress', async () => { - let repeatedClickTriggered = false - mockAddPluginCredential.mockImplementationOnce(async () => { - if (!repeatedClickTriggered) { - repeatedClickTriggered = true - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - } - return {} - }) - - render() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalledTimes(1) - }) - }) - - it('should call updatePluginCredential on confirm in edit mode', async () => { - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockUpdatePluginCredential).toHaveBeenCalled() - }) - }) - - it('should use empty credential name when authorization name is blank in edit mode', async () => { - mockFormValues = { - isCheckValidated: true, - values: { api_key: 'updated', __credential_id__: 'cred-1' }, - } - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => { - expect(mockUpdatePluginCredential).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - }), - ) - }) - }) - - it('should call onRemove when remove button clicked', () => { - const mockOnRemove = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) - expect(mockOnRemove).toHaveBeenCalled() - }) - - it('should stay open when clicking inside the modal from a popover', async () => { - // Use userEvent instead of fireEvent to avoid CI flakiness: userEvent - // awaits React act() between pointer/mouse/click so base-ui's dialog - // popup ref is guaranteed committed before outside-click detection runs. - const user = userEvent.setup() - const mockOnClose = vi.fn() - const mockOnPopoverClose = vi.fn() - - render( - , - ) - - const form = await screen.findByTestId('auth-form') - - await user.click(form) - - expect(mockOnClose).not.toHaveBeenCalled() - expect(mockOnPopoverClose).not.toHaveBeenCalled() - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should close on backdrop click through controlled open state', async () => { - const mockOnClose = vi.fn() - render() - - const backdrop = document.querySelector('.bg-background-overlay') - if (!backdrop) throw new Error('Expected dialog backdrop to render') - - fireEvent.pointerDown(backdrop) - fireEvent.mouseDown(backdrop) - fireEvent.click(backdrop) - - await waitFor(() => { - expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') - }) - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should close on backdrop click when nested inside another dialog', async () => { - const mockOnClose = vi.fn() - render( - - - - - , - ) - - const backdrop = screen - .getAllByRole('presentation', { hidden: true }) - .filter((element) => element.hasAttribute('data-open')) - .at(-1) - expect(backdrop).toBeInTheDocument() - - fireEvent.pointerDown(backdrop!) - fireEvent.mouseDown(backdrop!) - fireEvent.click(backdrop!) - - await waitFor(() => { - expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') - }) - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should render readme entrance when detail is provided', () => { - const payload = { ...basePayload, detail: { name: 'Test' } as never } - render() - - expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() - }) + } + render() + await user.type(await screen.findByLabelText(/API Key/), 'secret') + expect(screen.getByLabelText(/API Key/)).toHaveValue('secret') + expect(onClose).not.toHaveBeenCalled() + expect(onPopoverClose).not.toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx index 7b42d4409dcbc5..adbc1ad5581670 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/authorize-components.spec.tsx @@ -1,2057 +1,74 @@ -import type { PluginPayload } from '../../types' -import type { FormSchema } from '@/app/components/base/form/types' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { createAccountProfileQueryClient } from '@/test/console/account-profile' -import { createQueryClientWrapper } from '@/test/console/query-client' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { AuthCategory } from '../../types' - -const createWrapper = () => createQueryClientWrapper(createAccountProfileQueryClient()) - -// Mock API hooks - these make network requests so must be mocked -const mockGetPluginOAuthUrl = vi.fn() -const mockGetPluginOAuthClientSchema = vi.fn() -const mockSetPluginOAuthCustomClient = vi.fn() -const mockDeletePluginOAuthCustomClient = vi.fn() -const mockInvalidPluginOAuthClientSchema = vi.fn() -const mockAddPluginCredential = vi.fn() -const mockUpdatePluginCredential = vi.fn() -const mockGetPluginCredentialSchema = vi.fn() - +import AddApiKeyButton from '../add-api-key-button' +import AddOAuthButton from '../add-oauth-button' + +const commands = vi.hoisted(() => ({ + add: vi.fn(), + saveOAuth: vi.fn(), + authorize: vi.fn(), + invalidate: vi.fn(), +})) vi.mock('../../hooks/use-credential', () => ({ - useGetPluginOAuthUrlHook: () => ({ - mutateAsync: mockGetPluginOAuthUrl, - }), - useGetPluginOAuthClientSchemaHook: () => ({ - data: mockGetPluginOAuthClientSchema(), - isLoading: false, - }), - useSetPluginOAuthCustomClientHook: () => ({ - mutateAsync: mockSetPluginOAuthCustomClient, - }), - useDeletePluginOAuthCustomClientHook: () => ({ - mutateAsync: mockDeletePluginOAuthCustomClient, - }), - useInvalidPluginOAuthClientSchemaHook: () => mockInvalidPluginOAuthClientSchema, - useAddPluginCredentialHook: () => ({ - mutateAsync: mockAddPluginCredential, - }), - useUpdatePluginCredentialHook: () => ({ - mutateAsync: mockUpdatePluginCredential, - }), + useAddPluginCredentialHook: () => ({ mutateAsync: commands.add }), + useUpdatePluginCredentialHook: () => ({ mutateAsync: vi.fn() }), useGetPluginCredentialSchemaHook: () => ({ - data: mockGetPluginCredentialSchema(), - isLoading: false, - }), -})) - -// Mock openOAuthPopup - requires window operations -const mockOpenOAuthPopup = vi.fn() -vi.mock('@/hooks/use-oauth', () => ({ - openOAuthPopup: (...args: unknown[]) => mockOpenOAuthPopup(...args), -})) - -// Mock service/use-triggers - API service -vi.mock('@/service/use-triggers', () => ({ - useTriggerPluginDynamicOptions: () => ({ - data: { options: [] }, + data: [{ name: 'api_key', label: 'API Key', type: FormTypeEnum.secretInput, required: true }], isLoading: false, }), - useTriggerPluginDynamicOptionsInfo: () => ({ - data: null, - isLoading: false, - }), - useInvalidTriggerDynamicOptions: () => vi.fn(), -})) - -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ data: { accounts: [] } }), -})) - -// Mock AuthForm to control form validation in tests -const mockGetFormValues = vi.fn() -vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ - default: vi.fn().mockImplementation(({ ref }: { ref: { current: unknown } }) => { - if (ref) ref.current = { getFormValues: mockGetFormValues } - - return
Auth Form
- }), -})) - -const { mockToast } = vi.hoisted(() => { - const mockNotify = vi.fn() - return { - mockToast: { - success: (message: string, options?: Record) => - mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record) => - mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record) => - mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record) => - mockNotify({ type: 'info', message, ...options }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - } -}) - -vi.mock('@/app/notifications', () => ({ - toast: mockToast, + useGetPluginOAuthClientSchemaHook: () => ({ data: {}, isLoading: false }), + useGetPluginOAuthUrlHook: () => ({ mutateAsync: commands.authorize, isPending: false }), + useSetPluginOAuthCustomClientHook: () => ({ mutateAsync: commands.saveOAuth }), + useDeletePluginOAuthCustomClientHook: () => ({ mutateAsync: vi.fn() }), + useInvalidPluginOAuthClientSchemaHook: () => commands.invalidate, })) - -// Factory function for creating test PluginPayload -const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({ - category: AuthCategory.tool, - provider: 'test-provider', - ...overrides, +const payload = { category: AuthCategory.tool, provider: 'test-provider' } +beforeEach(() => { + commands.add.mockResolvedValue({}) + commands.saveOAuth.mockResolvedValue({}) }) -// Factory for form schemas -const createFormSchema = (overrides: Partial = {}): FormSchema => ({ - type: 'text-input' as FormSchema['type'], - name: 'test-field', - label: 'Test Field', - required: false, - ...overrides, -}) - -// ==================== AddApiKeyButton Tests ==================== -describe('AddApiKeyButton', () => { - let AddApiKeyButton: typeof import('../add-api-key-button').default - - beforeEach(async () => { - vi.clearAllMocks() - mockGetPluginCredentialSchema.mockReturnValue([]) - const importedAddApiKeyButton = await import('../add-api-key-button') - AddApiKeyButton = importedAddApiKeyButton.default - }) - - describe('Rendering', () => { - it('should render button with default text', () => { - const pluginPayload = createPluginPayload() - - render(, { wrapper: createWrapper() }) - - expect(screen.getByRole('button')).toHaveTextContent('Use Api Key') - }) - - it('should render button with custom text', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByRole('button')).toHaveTextContent('Custom API Key') - }) - }) - - describe('Props Testing', () => { - it('should disable button when disabled prop is true', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByRole('button')).toBeDisabled() - }) - - it('should not disable button when disabled prop is false', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByRole('button')).not.toBeDisabled() - }) - - it('should accept formSchemas prop', () => { - const pluginPayload = createPluginPayload() - const formSchemas = [createFormSchema({ name: 'api_key', label: 'API Key' })] - - expect(() => { - render(, { - wrapper: createWrapper(), - }) - }).not.toThrow() - }) - }) - - describe('User Interactions', () => { - it('should open modal when button is clicked', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - }) - - it('should not open modal when button is disabled', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - // Modal should not appear - expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty pluginPayload properties', () => { - const pluginPayload = createPluginPayload({ - provider: '', - providerType: undefined, - }) - - expect(() => { - render(, { wrapper: createWrapper() }) - }).not.toThrow() - }) - - it('should handle all auth categories', () => { - const categories = [ - AuthCategory.tool, - AuthCategory.datasource, - AuthCategory.model, - AuthCategory.trigger, - ] - - categories.forEach((category) => { - const pluginPayload = createPluginPayload({ category }) - const { unmount } = render(, { - wrapper: createWrapper(), - }) - expect(screen.getByRole('button')).toBeInTheDocument() - unmount() - }) - }) - }) - - describe('Modal Behavior', () => { - it('should close modal when onClose is called from ApiKeyModal', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - - render(, { wrapper: createWrapper() }) - - // Open modal - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - - // Close modal via cancel button - fireEvent.click(screen.getByText('common.operation.cancel')) - - await waitFor(() => { - expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument() - }) - }) - - it('should call onUpdate when provided and modal triggers update', async () => { - const pluginPayload = createPluginPayload() - const onUpdate = vi.fn() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - - render(, { - wrapper: createWrapper(), - }) - - // Open modal - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - }) - }) +it('opens an API key form and closes it after creating a credential', async () => { + const user = userEvent.setup() + const onUpdate = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'Use Api Key' })) + await user.type(await screen.findByLabelText(/API Key/), 'new-secret') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(commands.add).toHaveBeenCalledWith( + expect.objectContaining({ credentials: { api_key: 'new-secret' } }), + ) + expect(onUpdate).toHaveBeenCalledOnce() }) -// ==================== AddOAuthButton Tests ==================== -describe('AddOAuthButton', () => { - let AddOAuthButton: typeof import('../add-oauth-button').default - - beforeEach(async () => { - vi.clearAllMocks() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - client_params: {}, - redirect_uri: 'https://example.com/callback', - }) - mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) - const importedAddOAuthButton = await import('../add-oauth-button') - AddOAuthButton = importedAddOAuthButton.default - }) - - describe('Rendering - Not Configured State', () => { - it('should render setup OAuth button when not configured', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - }) - - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument() - }) - }) - - describe('Rendering - Configured State', () => { - it('should render OAuth button when system OAuth params exist', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: true, - }) - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('Connect OAuth')).toBeInTheDocument() - }) - - it('should render OAuth button when custom client is enabled', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: false, - }) - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('OAuth')).toBeInTheDocument() - }) - - it('should show custom badge when custom client is enabled', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: false, - }) - - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument() - }) - }) - - describe('Props Testing', () => { - it('should disable button when disabled prop is true', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - }) - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByRole('button')).toBeDisabled() - }) - - it('should apply custom className', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: false, - }) - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('use oauth').closest('.custom-class')).toBeInTheDocument() - }) - - it('should use oAuthData prop when provided', () => { - const pluginPayload = createPluginPayload() - const oAuthData = { - schema: [], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: true, - client_params: {}, - redirect_uri: 'https://custom.example.com/callback', - } - - render(, { - wrapper: createWrapper(), - }) - - // Should render configured button since oAuthData has is_system_oauth_params_exists=true - expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should trigger OAuth flow when configured button is clicked', async () => { - const pluginPayload = createPluginPayload() - const onUpdate = vi.fn() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: false, - }) - mockGetPluginOAuthUrl.mockResolvedValue({ - authorization_url: 'https://oauth.example.com/auth', - }) - - render(, { - wrapper: createWrapper(), - }) - - // Click the main button area (left side) → opens visibility picker - const buttonText = screen.getByText('use oauth') - fireEvent.click(buttonText) - - // Confirm the picker to actually kick off OAuth - const confirmButton = await screen.findByText('plugin.auth.authorize') - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(mockGetPluginOAuthUrl).toHaveBeenCalled() - }) - }) - - it('should open settings when setup button is clicked', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - - it('should not trigger OAuth when no authorization_url is returned', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: false, - }) - mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: '' }) - - render(, { wrapper: createWrapper() }) - - const buttonText = screen.getByText('use oauth') - fireEvent.click(buttonText) - - // Confirm the visibility picker to kick off the OAuth request - const confirmButton = await screen.findByText('plugin.auth.authorize') - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(mockGetPluginOAuthUrl).toHaveBeenCalled() - }) - - expect(mockOpenOAuthPopup).not.toHaveBeenCalled() - }) - - it('should call onUpdate callback after successful OAuth', async () => { - const pluginPayload = createPluginPayload() - const onUpdate = vi.fn() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: false, - }) - mockGetPluginOAuthUrl.mockResolvedValue({ - authorization_url: 'https://oauth.example.com/auth', - }) - // Simulate openOAuthPopup calling the success callback - mockOpenOAuthPopup.mockImplementation((url, callback) => { - callback?.() - }) - - render(, { - wrapper: createWrapper(), - }) - - const buttonText = screen.getByText('use oauth') - fireEvent.click(buttonText) - - // Confirm the visibility picker to kick off the OAuth request - const confirmButton = await screen.findByText('plugin.auth.authorize') - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(mockOpenOAuthPopup).toHaveBeenCalledWith( - 'https://oauth.example.com/auth', - expect.any(Function), - ) - }) - - // Verify onUpdate was called through the callback - expect(onUpdate).toHaveBeenCalled() - }) - - it('should open OAuth settings when settings icon is clicked', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: false, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - const settingsButton = screen.getByRole('button', { - name: /plugin\.auth\.oauthClientSettings/i, - }) - fireEvent.click(settingsButton) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - - it('should close OAuth settings modal when onClose is called', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - // Open settings - fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - - // Close settings via cancel button - fireEvent.click(screen.getByText('common.operation.cancel')) - - await waitFor(() => { - expect(screen.queryByText('plugin.auth.oauthClientSettings')).not.toBeInTheDocument() - }) - }) - }) - - describe('Schema Processing', () => { - it('should handle is_system_oauth_params_exists state', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: true, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - // Should show the configured button, not setup button - expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() - }) - - it('should open OAuth settings modal with correct data', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) - - await waitFor(() => { - // OAuthClientSettings modal should open - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - - it('should handle client_params defaults in schema', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ +it('opens settings for an unconfigured OAuth provider and saves the entered client', async () => { + const user = userEvent.setup() + render( + , { wrapper: createWrapper() }) - - fireEvent.click(screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i })) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - - it('should handle __auth_client__ logic when configured with system OAuth and no custom client', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: true, - client_params: {}, - }) - - render(, { wrapper: createWrapper() }) - - // Should render configured button (not setup button) - expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() - }) - - it('should open OAuth settings when system OAuth params exist', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: true, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByRole('button', { name: /plugin\.auth\.oauthClientSettings/i })) - - await waitFor(() => { - // OAuthClientSettings modal should open - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - }) - - describe('Clipboard Operations', () => { - it('should have clipboard API available for copy operations', async () => { - const pluginPayload = createPluginPayload() - const mockWriteText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: mockWriteText }, - configurable: true, - }) - - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) - - await waitFor(() => { - // OAuthClientSettings modal opens - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - - // Verify clipboard API is available - expect(navigator.clipboard.writeText).toBeDefined() - }) - }) - - describe('__auth_client__ Logic', () => { - it('should return default when not configured and system OAuth params exist', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: true, - client_params: {}, - }) - - render(, { wrapper: createWrapper() }) - - // When isConfigured is true (is_system_oauth_params_exists=true), it should show the configured button - expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() - }) - - it('should return custom when not configured and no system OAuth params', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - client_params: {}, - }) - - render(, { wrapper: createWrapper() }) - - // When not configured, it should show the setup button - expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty schema', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - }) - - expect(() => { - render(, { wrapper: createWrapper() }) - }).not.toThrow() - }) - - it('should handle undefined oAuthData fields', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue(undefined) - - expect(() => { - render(, { wrapper: createWrapper() }) - }).not.toThrow() - }) - - it('should handle null client_params', () => { - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'test' })], - is_oauth_custom_client_enabled: true, - is_system_oauth_params_exists: true, - client_params: null, - }) - - expect(() => { - render(, { wrapper: createWrapper() }) - }).not.toThrow() - }) - }) -}) - -// ==================== ApiKeyModal Tests ==================== -describe('ApiKeyModal', () => { - let ApiKeyModal: typeof import('../api-key-modal').default - - beforeEach(async () => { - vi.clearAllMocks() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key', required: true }), - ]) - mockAddPluginCredential.mockResolvedValue({}) - mockUpdatePluginCredential.mockResolvedValue({}) - // Reset form values mock to return validation failed by default - mockGetFormValues.mockReturnValue({ - isCheckValidated: false, - values: {}, - }) - const importedApiKeyModal = await import('../api-key-modal') - ApiKeyModal = importedApiKeyModal.default - }) - - describe('Rendering', () => { - it('should render modal with title', () => { - const pluginPayload = createPluginPayload() - - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - - it('should render modal with subtitle', () => { - const pluginPayload = createPluginPayload() - - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('plugin.auth.useApiAuthDesc')).toBeInTheDocument() - }) - - it('should render form when data is loaded', () => { - const pluginPayload = createPluginPayload() - - render(, { wrapper: createWrapper() }) - - // AuthForm is mocked, so check for the mock element - expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() - }) - }) - - describe('Props Testing', () => { - it('should call onClose when modal is closed', () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - - render(, { - wrapper: createWrapper(), - }) - - // Find and click cancel button - const cancelButton = screen.getByText('common.operation.cancel') - fireEvent.click(cancelButton) - - expect(onClose).toHaveBeenCalled() - }) - - it('should disable confirm button when disabled prop is true', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - const confirmButton = screen.getByText('common.operation.save') - expect(confirmButton.closest('button')).toBeDisabled() - }) - - it('should show modal when editValues is provided', () => { - const pluginPayload = createPluginPayload() - const editValues = { - __name__: 'Test Name', - __credential_id__: 'test-id', - api_key: 'test-key', - } - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - - it('should use formSchemas from props when provided', () => { - const pluginPayload = createPluginPayload() - const customSchemas = [createFormSchema({ name: 'custom_field', label: 'Custom Field' })] - - render(, { - wrapper: createWrapper(), - }) - - // AuthForm is mocked, verify modal renders - expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() - }) - }) - - describe('Form Behavior', () => { - it('should render AuthForm component', () => { - const pluginPayload = createPluginPayload() - - render(, { wrapper: createWrapper() }) - - // AuthForm is mocked, verify it's rendered - expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() - }) - - it('should render modal with editValues', () => { - const pluginPayload = createPluginPayload() - const editValues = { - __name__: 'Existing Name', - api_key: 'existing-key', - } - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - }) - - describe('Form Submission - handleConfirm', () => { - beforeEach(() => { - // Default: form validation passes with empty values - mockGetFormValues.mockReturnValue({ - isCheckValidated: true, - values: { - __name__: 'Test Name', - api_key: 'test-api-key', - }, - }) - }) - - it('should call addPluginCredential when creating new credential', async () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - const onUpdate = vi.fn() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - mockAddPluginCredential.mockResolvedValue({}) - - render(, { - wrapper: createWrapper(), - }) - - // Click confirm button - const confirmButton = screen.getByText('common.operation.save') - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalled() - }) - }) - - it('should call updatePluginCredential when editing existing credential', async () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - const onUpdate = vi.fn() - const editValues = { - __name__: 'Test Credential', - __credential_id__: 'test-credential-id', - api_key: 'existing-key', - } - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - mockUpdatePluginCredential.mockResolvedValue({}) - mockGetFormValues.mockReturnValue({ - isCheckValidated: true, - values: { - __name__: 'Test Credential', - __credential_id__: 'test-credential-id', - api_key: 'updated-key', - }, - }) - - render( - , - { wrapper: createWrapper() }, - ) - - // Click confirm button - const confirmButton = screen.getByText('common.operation.save') - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(mockUpdatePluginCredential).toHaveBeenCalled() - }) - }) - - it('should call onClose and onUpdate after successful submission', async () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - const onUpdate = vi.fn() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - mockAddPluginCredential.mockResolvedValue({}) - - render(, { - wrapper: createWrapper(), - }) - - // Click confirm button - const confirmButton = screen.getByText('common.operation.save') - fireEvent.click(confirmButton) - - await waitFor(() => { - expect(onClose).toHaveBeenCalled() - expect(onUpdate).toHaveBeenCalled() - }) - }) - - it('should not call API when form validation fails', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key', required: true }), - ]) - mockGetFormValues.mockReturnValue({ - isCheckValidated: false, - values: {}, - }) - - render(, { wrapper: createWrapper() }) - - // Click confirm button - const confirmButton = screen.getByText('common.operation.save') - fireEvent.click(confirmButton) - - // Verify API was not called since validation failed synchronously - expect(mockAddPluginCredential).not.toHaveBeenCalled() - }) - - it('should handle doingAction state to prevent double submission', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - // Make the API call slow - mockAddPluginCredential.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)), - ) - - render(, { wrapper: createWrapper() }) - - // Click confirm button twice quickly - const confirmButton = screen.getByText('common.operation.save') - fireEvent.click(confirmButton) - fireEvent.click(confirmButton) - - // Should only be called once due to doingAction guard - await waitFor(() => { - expect(mockAddPluginCredential).toHaveBeenCalledTimes(1) - }) - }) - - it('should return early if doingActionRef is true during concurrent clicks', async () => { - const pluginPayload = createPluginPayload() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - - // Create a promise that we can control - let resolveFirstCall: (value?: unknown) => void = () => {} - let apiCallCount = 0 - - mockAddPluginCredential.mockImplementation(() => { - apiCallCount++ - if (apiCallCount === 1) { - // First call: return a pending promise - return new Promise((resolve) => { - resolveFirstCall = resolve - }) - } - // Subsequent calls should not happen but return resolved promise - return Promise.resolve({}) - }) - - render(, { wrapper: createWrapper() }) - - const confirmButton = screen.getByText('common.operation.save') - - // First click starts the request - fireEvent.click(confirmButton) - - // Wait for the first API call to be made - await waitFor(() => { - expect(apiCallCount).toBe(1) - }) - - // Second click while first request is still pending should be ignored - fireEvent.click(confirmButton) - - // Verify only one API call was made (no additional calls) - expect(apiCallCount).toBe(1) - - // Clean up by resolving the promise - await act(async () => { - resolveFirstCall() - }) - }) - - it('should call onRemove when extra button is clicked in edit mode', async () => { - const pluginPayload = createPluginPayload() - const onRemove = vi.fn() - const editValues = { - __name__: 'Test Credential', - __credential_id__: 'test-credential-id', - } - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - - render( - , - { wrapper: createWrapper() }, - ) - - // Find and click the remove button - const removeButton = screen.getByText('common.operation.remove') - fireEvent.click(removeButton) - - expect(onRemove).toHaveBeenCalled() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty credentials schema', () => { - const pluginPayload = createPluginPayload() - mockGetPluginCredentialSchema.mockReturnValue([]) - - render(, { wrapper: createWrapper() }) - - // Should still render the modal with authorization name field - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - - it('should handle undefined detail in pluginPayload', () => { - const pluginPayload = createPluginPayload({ detail: undefined }) - - expect(() => { - render(, { wrapper: createWrapper() }) - }).not.toThrow() - }) - - it('should handle form schema with default values', () => { - const pluginPayload = createPluginPayload() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key', default: 'default-key' }), - ]) - - expect(() => { - render(, { wrapper: createWrapper() }) - }).not.toThrow() - - expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() - }) - }) -}) - -// ==================== OAuthClientSettings Tests ==================== -describe('OAuthClientSettings', () => { - let OAuthClientSettings: typeof import('../oauth-client-settings').default - - beforeEach(async () => { - vi.clearAllMocks() - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - mockDeletePluginOAuthCustomClient.mockResolvedValue({}) - const importedOAuthClientSettings = await import('../oauth-client-settings') - OAuthClientSettings = importedOAuthClientSettings.default - }) - - const defaultSchemas: FormSchema[] = [ - createFormSchema({ name: 'client_id', label: 'Client ID', required: true }), - createFormSchema({ name: 'client_secret', label: 'Client Secret', required: true }), - ] - - describe('Rendering', () => { - it('should render modal with correct title', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - - it('should render Save and Auth button', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument() - }) - - it('should render Save Only button', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument() - }) - - it('should render Cancel button', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() - }) - - it('should render form from schemas', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - // AuthForm is mocked - expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() - }) - }) - - describe('Props Testing', () => { - it('should call onClose when cancel button is clicked', () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(screen.getByText('common.operation.cancel')) - expect(onClose).toHaveBeenCalled() - }) - - it('should disable buttons when disabled prop is true', () => { - const pluginPayload = createPluginPayload() - - render( - , - { wrapper: createWrapper() }, - ) - - const confirmButton = screen.getByText('plugin.auth.saveAndAuth') - expect(confirmButton.closest('button')).toBeDisabled() - }) - - it('should render with editValues', () => { - const pluginPayload = createPluginPayload() - const editValues = { - client_id: 'existing-client-id', - client_secret: 'existing-secret', - __oauth_client__: 'custom', - } - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - - describe('Remove Button', () => { - it('should show remove button when custom client and hasOriginalClientParams', () => { - const pluginPayload = createPluginPayload() - const schemasWithOAuthClient: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.getByText('common.operation.remove')).toBeInTheDocument() - }) - - it('should not show remove button when using default client', () => { - const pluginPayload = createPluginPayload() - const schemasWithOAuthClient: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'default', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() - }) - }) - - describe('Form Submission', () => { - beforeEach(() => { - // Default: form validation passes - mockGetFormValues.mockReturnValue({ - isCheckValidated: true, - values: { - __oauth_client__: 'custom', - client_id: 'test-client-id', - client_secret: 'test-secret', - }, - }) - }) - - it('should render Save and Auth button that is clickable', async () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - const saveAndAuthButton = screen.getByText('plugin.auth.saveAndAuth') - expect(saveAndAuthButton).toBeInTheDocument() - expect(saveAndAuthButton.closest('button')).not.toBeDisabled() - }) - - it('should call setPluginOAuthCustomClient when Save Only is clicked', async () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - const onUpdate = vi.fn() - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - - render( - , - { wrapper: createWrapper() }, - ) - - // Click Save Only button - fireEvent.click(screen.getByText('plugin.auth.saveOnly')) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() - }) - }) - - it('should call onClose and onUpdate after successful submission', async () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - const onUpdate = vi.fn() - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(screen.getByText('plugin.auth.saveOnly')) - - await waitFor(() => { - expect(onClose).toHaveBeenCalled() - expect(onUpdate).toHaveBeenCalled() - }) - }) - - it('should request authorization after handleConfirmAndAuthorize', async () => { - const pluginPayload = createPluginPayload() - const onRequestAuthorization = vi.fn().mockResolvedValue(undefined) - const onClose = vi.fn() - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - - render( - , - { wrapper: createWrapper() }, - ) - - // Click Save and Auth button - fireEvent.click(screen.getByText('plugin.auth.saveAndAuth')) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() - expect(onRequestAuthorization).toHaveBeenCalled() - }) - }) - - it('should handle form with empty values', () => { - const pluginPayload = createPluginPayload() - - render(, { - wrapper: createWrapper(), - }) - - // Modal should render with save buttons - expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument() - expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument() - }) - - it('should call deletePluginOAuthCustomClient when Remove is clicked', async () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - const onUpdate = vi.fn() - mockDeletePluginOAuthCustomClient.mockResolvedValue({}) - - const schemasWithOAuthClient: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - // Click Remove button - fireEvent.click(screen.getByText('common.operation.remove')) - - await waitFor(() => { - expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled() - }) - }) - - it('should call onClose and onUpdate after successful removal', async () => { - const pluginPayload = createPluginPayload() - const onClose = vi.fn() - const onUpdate = vi.fn() - mockDeletePluginOAuthCustomClient.mockResolvedValue({}) - - const schemasWithOAuthClient: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(screen.getByText('common.operation.remove')) - - await waitFor(() => { - expect(onClose).toHaveBeenCalled() - expect(onUpdate).toHaveBeenCalled() - }) - }) - - it('should prevent double submission when doingAction is true', async () => { - const pluginPayload = createPluginPayload() - // Make the API call slow - mockSetPluginOAuthCustomClient.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)), - ) - - render(, { - wrapper: createWrapper(), - }) - - // Click Save Only button twice quickly - const saveButton = screen.getByText('plugin.auth.saveOnly') - fireEvent.click(saveButton) - fireEvent.click(saveButton) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1) - }) - }) - - it('should return early from handleConfirm if doingActionRef is true', async () => { - const pluginPayload = createPluginPayload() - let resolveFirstCall: (value?: unknown) => void = () => {} - let apiCallCount = 0 - - mockSetPluginOAuthCustomClient.mockImplementation(() => { - apiCallCount++ - if (apiCallCount === 1) { - return new Promise((resolve) => { - resolveFirstCall = resolve - }) - } - return Promise.resolve({}) - }) - - render(, { - wrapper: createWrapper(), - }) - - const saveButton = screen.getByText('plugin.auth.saveOnly') - - // First click starts the request - fireEvent.click(saveButton) - - // Wait for the first API call to be made - await waitFor(() => { - expect(apiCallCount).toBe(1) - }) - - // Second click while first request is pending should be ignored - fireEvent.click(saveButton) - - // Verify only one API call was made (no additional calls) - expect(apiCallCount).toBe(1) - - // Clean up - await act(async () => { - resolveFirstCall() - }) - }) - - it('should return early from handleRemove if doingActionRef is true', async () => { - const pluginPayload = createPluginPayload() - let resolveFirstCall: (value?: unknown) => void = () => {} - let deleteCallCount = 0 - - mockDeletePluginOAuthCustomClient.mockImplementation(() => { - deleteCallCount++ - if (deleteCallCount === 1) { - return new Promise((resolve) => { - resolveFirstCall = resolve - }) - } - return Promise.resolve({}) - }) - - const schemasWithOAuthClient: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - const removeButton = screen.getByText('common.operation.remove') - - // First click starts the delete request - fireEvent.click(removeButton) - - // Wait for the first delete call to be made - await waitFor(() => { - expect(deleteCallCount).toBe(1) - }) - - // Second click while first request is pending should be ignored - fireEvent.click(removeButton) - - // Verify only one delete call was made (no additional calls) - expect(deleteCallCount).toBe(1) - - // Clean up - await act(async () => { - resolveFirstCall() - }) - }) - }) - - describe('Edge Cases', () => { - it('should handle empty schemas', () => { - const pluginPayload = createPluginPayload() - - expect(() => { - render(, { - wrapper: createWrapper(), - }) - }).not.toThrow() - }) - - it('should handle schemas without default values', () => { - const pluginPayload = createPluginPayload() - const schemasWithoutDefaults: FormSchema[] = [ - createFormSchema({ name: 'field1', label: 'Field 1', default: undefined }), - ] - - expect(() => { - render( - , - { wrapper: createWrapper() }, - ) - }).not.toThrow() - }) - - it('should handle undefined editValues', () => { - const pluginPayload = createPluginPayload() - - expect(() => { - render( - , - { wrapper: createWrapper() }, - ) - }).not.toThrow() - }) - }) - - describe('Branch Coverage - defaultValues computation', () => { - it('should compute defaultValues from schemas with default values', () => { - const pluginPayload = createPluginPayload() - const schemasWithDefaults: FormSchema[] = [ - createFormSchema({ name: 'client_id', label: 'Client ID', default: 'default-id' }), - createFormSchema({ - name: 'client_secret', - label: 'Client Secret', - default: 'default-secret', - }), - ] - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - - it('should skip schemas without default values in defaultValues computation', () => { - const pluginPayload = createPluginPayload() - const mixedSchemas: FormSchema[] = [ - createFormSchema({ name: 'field_with_default', label: 'With Default', default: 'value' }), - createFormSchema({ - name: 'field_without_default', - label: 'Without Default', - default: undefined, - }), - createFormSchema({ name: 'field_with_empty', label: 'Empty Default', default: '' }), - ] - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - - describe('Branch Coverage - __oauth_client__ value', () => { - beforeEach(() => { - mockGetFormValues.mockReturnValue({ - isCheckValidated: true, - values: { - __oauth_client__: 'default', - client_id: 'test-id', - }, - }) - }) - - it('should send enable_oauth_custom_client=false when __oauth_client__ is default', async () => { - const pluginPayload = createPluginPayload() - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - - render(, { - wrapper: createWrapper(), - }) - - fireEvent.click(screen.getByText('plugin.auth.saveOnly')) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith( - expect.objectContaining({ - enable_oauth_custom_client: false, - }), - ) - }) - }) - - it('should send enable_oauth_custom_client=true when __oauth_client__ is custom', async () => { - const pluginPayload = createPluginPayload() - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - mockGetFormValues.mockReturnValue({ - isCheckValidated: true, - values: { - __oauth_client__: 'custom', - client_id: 'test-id', - }, - }) - - render(, { - wrapper: createWrapper(), - }) - - fireEvent.click(screen.getByText('plugin.auth.saveOnly')) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith( - expect.objectContaining({ - enable_oauth_custom_client: true, - }), - ) - }) - }) - }) - - describe('Branch Coverage - authorization request callback', () => { - beforeEach(() => { - mockGetFormValues.mockReturnValue({ - isCheckValidated: true, - values: { __oauth_client__: 'custom' }, - }) - }) - - it('should request authorization when Save and Auth is clicked', async () => { - const pluginPayload = createPluginPayload() - const onRequestAuthorization = vi.fn().mockResolvedValue(undefined) - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(screen.getByText('plugin.auth.saveAndAuth')) - - await waitFor(() => { - expect(onRequestAuthorization).toHaveBeenCalled() - }) - }) - - it('should save without an authorization request callback', async () => { - const pluginPayload = createPluginPayload() - mockSetPluginOAuthCustomClient.mockResolvedValue({}) - - render(, { - wrapper: createWrapper(), - }) - - fireEvent.click(screen.getByText('plugin.auth.saveAndAuth')) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() - }) - }) - }) - - describe('Branch Coverage - disabled states', () => { - it('should disable buttons when disabled prop is true', () => { - const pluginPayload = createPluginPayload() - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.getByText('plugin.auth.saveAndAuth').closest('button')).toBeDisabled() - expect(screen.getByText('plugin.auth.saveOnly').closest('button')).toBeDisabled() - }) - - it('should disable Remove button when editValues is undefined', () => { - const pluginPayload = createPluginPayload() - const schemasWithOAuthClient: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - // Remove button should exist but be disabled - const removeButton = screen.queryByText('common.operation.remove') - if (removeButton) { - expect(removeButton.closest('button')).toBeDisabled() - } - }) - - it('should disable Remove button when disabled prop is true', () => { - const pluginPayload = createPluginPayload() - const schemasWithOAuthClient: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - const removeButton = screen.getByText('common.operation.remove') - expect(removeButton.closest('button')).toBeDisabled() - }) - }) - - describe('Branch Coverage - pluginPayload.detail', () => { - it('should render ReadmeEntrance when pluginPayload has detail', () => { - const pluginPayload = createPluginPayload({ - detail: { - name: 'test-plugin', - label: { en_US: 'Test Plugin' }, - } as unknown as PluginPayload['detail'], - }) - - render(, { - wrapper: createWrapper(), - }) - - // ReadmeEntrance should be rendered (it's mocked in vitest.setup) - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - - it('should not render ReadmeEntrance when pluginPayload has no detail', () => { - const pluginPayload = createPluginPayload({ detail: undefined }) - - render(, { - wrapper: createWrapper(), - }) - - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - - describe('Branch Coverage - footerSlot conditions', () => { - it('should show Remove button only when __oauth_client__=custom AND hasOriginalClientParams=true', () => { - const pluginPayload = createPluginPayload() - const schemasWithCustomOAuth: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.getByText('common.operation.remove')).toBeInTheDocument() - }) - - it('should not show Remove button when hasOriginalClientParams=false', () => { - const pluginPayload = createPluginPayload() - const schemasWithCustomOAuth: FormSchema[] = [ - { - name: '__oauth_client__', - label: 'OAuth Client', - type: 'radio' as FormSchema['type'], - options: [ - { label: 'Default', value: 'default' }, - { label: 'Custom', value: 'custom' }, - ], - default: 'custom', - required: false, - }, - ...defaultSchemas, - ] - - render( - , - { wrapper: createWrapper() }, - ) - - expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() - }) - }) -}) - -// ==================== Integration Tests ==================== -describe('Authorize Components Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetPluginCredentialSchema.mockReturnValue([ - createFormSchema({ name: 'api_key', label: 'API Key' }), - ]) - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - redirect_uri: 'https://example.com/callback', - }) - }) - - describe('AddApiKeyButton -> ApiKeyModal Flow', () => { - it('should open ApiKeyModal when AddApiKeyButton is clicked', async () => { - const AddApiKeyButton = (await import('../add-api-key-button')).default - const pluginPayload = createPluginPayload() - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() - }) - }) - }) - - describe('AddOAuthButton -> OAuthClientSettings Flow', () => { - it('should open OAuthClientSettings when setup button is clicked', async () => { - const AddOAuthButton = (await import('../add-oauth-button')).default - const pluginPayload = createPluginPayload() - mockGetPluginOAuthClientSchema.mockReturnValue({ - schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], - is_oauth_custom_client_enabled: false, - is_system_oauth_params_exists: false, - redirect_uri: 'https://example.com/callback', - }) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) - - await waitFor(() => { - expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() - }) - }) - }) + }} + />, + ) + await user.click(screen.getByRole('button', { name: /plugin.auth.setupOAuth/ })) + await user.type(await screen.findByRole('textbox', { name: /Client ID/ }), 'client-123') + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveOnly' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(commands.saveOAuth).toHaveBeenCalledWith({ + client_params: { client_id: 'client-123' }, + enable_oauth_custom_client: true, + }) + expect(commands.authorize).not.toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx index 8e18d04a133364..4fce8f6917c244 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/index.spec.tsx @@ -76,11 +76,6 @@ vi.mock('@/service/use-triggers', () => ({ data: { options: [] }, isLoading: false, }), - useTriggerPluginDynamicOptionsInfo: () => ({ - data: null, - isLoading: false, - }), - useInvalidTriggerDynamicOptions: () => vi.fn(), })) // Factory function for creating test PluginPayload diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx index e3ad0b19dca859..bace814622495b 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/oauth-client-settings.spec.tsx @@ -1,410 +1,119 @@ -import type { OAuthClientSettingsProps } from '../oauth-client-settings' -import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import type { FormSchema } from '@/app/components/base/form/types' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useState } from 'react' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { AuthCategory } from '../../types' +import OAuthClientSettings from '../oauth-client-settings' -const mockNotify = vi.fn() -const mockToast = { - success: (message: string, options?: Record) => - mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record) => - mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record) => - mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record) => - mockNotify({ type: 'info', message, ...options }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), -} - -vi.mock('@/app/notifications', () => ({ - toast: mockToast, -})) -const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({}) -const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({}) -const mockInvalidPluginOAuthClientSchema = vi.fn() -let mockFormValues = { - isCheckValidated: true, - values: { __oauth_client__: 'custom', client_id: 'test-id' }, -} -let mockAuthFormProps: Record | undefined - +const commands = vi.hoisted(() => ({ save: vi.fn(), remove: vi.fn(), invalidate: vi.fn() })) vi.mock('../../hooks/use-credential', () => ({ - useSetPluginOAuthCustomClientHook: () => ({ - mutateAsync: mockSetPluginOAuthCustomClient, - }), - useDeletePluginOAuthCustomClientHook: () => ({ - mutateAsync: mockDeletePluginOAuthCustomClient, - }), - useInvalidPluginOAuthClientSchemaHook: () => mockInvalidPluginOAuthClientSchema, + useSetPluginOAuthCustomClientHook: () => ({ mutateAsync: commands.save }), + useDeletePluginOAuthCustomClientHook: () => ({ mutateAsync: commands.remove }), + useInvalidPluginOAuthClientSchemaHook: () => commands.invalidate, })) - -vi.mock('../../../readme-panel/entrance', () => ({ - ReadmeEntrance: () =>
, -})) - -vi.mock('@/app/components/base/form/form-scenarios/auth', () => { - const MockAuthForm = ({ - ref, - ...props - }: { ref?: React.Ref } & Record) => { - mockAuthFormProps = props - React.useImperativeHandle(ref, () => ({ - getFormValues: () => mockFormValues, - })) - return
- } - - return { - default: MockAuthForm, - } -}) - -vi.mock('@tanstack/react-form', () => ({ - useForm: (config: Record) => ({ - store: { subscribe: vi.fn(), getState: () => ({ values: config.defaultValues || {} }) }, - }), - useStore: (_store: unknown, selector: (state: Record) => unknown) => { - return selector({ values: { __oauth_client__: 'custom' } }) +vi.mock('@/app/notifications', () => ({ toast: { success: vi.fn() } })) +const payload = { category: AuthCategory.tool, provider: 'test-provider' } +const schemas: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'Client type', + type: FormTypeEnum.textInput, + default: 'custom', + required: true, }, -})) - -const basePayload = { - category: AuthCategory.tool, - provider: 'test-provider', -} + { name: 'client_id', label: 'Client ID', type: FormTypeEnum.textInput, required: true }, +] +beforeEach(() => { + commands.save.mockResolvedValue({}) + commands.remove.mockResolvedValue({}) +}) -const defaultSchemas = [ - { name: 'client_id', label: 'Client ID', type: 'text-input', required: true }, -] as never +it('validates client fields and requests authorization only after a successful save', async () => { + const user = userEvent.setup() + const authorize = vi.fn() + const onClose = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) + expect(commands.save).not.toHaveBeenCalled() + await user.type(screen.getByRole('textbox', { name: /Client ID/ }), 'new-client') + commands.save.mockRejectedValueOnce(new Error('Save failed')) + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) + await waitFor(() => expect(commands.save).toHaveBeenCalledOnce()) + expect(authorize).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) + await waitFor(() => expect(authorize).toHaveBeenCalledOnce()) + expect(commands.save).toHaveBeenLastCalledWith({ + client_params: { client_id: 'new-client' }, + enable_oauth_custom_client: true, + }) + expect(commands.invalidate).toHaveBeenCalledOnce() +}) -const PopoverSettingsHarness = ({ - OAuthClientSettings, - onClose, - onPopoverClose, -}: { - OAuthClientSettings: React.FC - onClose: () => void - onPopoverClose: () => void -}) => { - const [open, setOpen] = React.useState(true) +it('prevents duplicate saves and supports removing the existing custom client', async () => { + const user = userEvent.setup() + const request = createDeferred() + commands.save.mockReturnValue(request.promise) + const onUpdate = vi.fn() + render( + , + ) + await user.dblClick(screen.getByRole('button', { name: 'plugin.auth.saveOnly' })) + expect(commands.save).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: 'common.operation.remove' })).toBeDisabled() + request.resolve({}) + await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce()) + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + await waitFor(() => expect(commands.remove).toHaveBeenCalledOnce()) + expect(onUpdate).toHaveBeenCalledTimes(2) +}) - return ( - { - setOpen(nextOpen) - if (!nextOpen) onPopoverClose() - }} - > - OAuth} /> - -
+it('keeps the source popover open when typing in the OAuth settings dialog', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const onPopoverClose = vi.fn() + const Harness = () => { + const [open, setOpen] = useState(true) + return ( + { + setOpen(next) + if (!next) onPopoverClose() + }} + > + OAuth} /> + -
-
-
- ) -} - -const ControlledSettingsHarness = ({ - OAuthClientSettings, - onClose, -}: { - OAuthClientSettings: React.FC - onClose: () => void -}) => { - const [open, setOpen] = React.useState(true) - - return ( - <> -
{String(open)}
- - - ) -} - -describe('OAuthClientSettings', () => { - let OAuthClientSettings: React.FC - - beforeEach(async () => { - vi.clearAllMocks() - mockFormValues = { - isCheckValidated: true, - values: { __oauth_client__: 'custom', client_id: 'test-id' }, - } - mockAuthFormProps = undefined - const mod = await import('../oauth-client-settings') - OAuthClientSettings = mod.default - }) - - it('should render modal with correct title', () => { - render() - - expect( - screen.getByRole('heading', { name: 'plugin.auth.oauthClientSettings' }), - ).toBeInTheDocument() - }) - - it('should render auth form', () => { - render() - - expect(screen.getByTestId('auth-form')).toBeInTheDocument() - }) - - it('should render backdrop when nested inside another dialog', () => { - render( - - - - - , - ) - - const openBackdrops = screen - .getAllByRole('presentation', { hidden: true }) - .filter((element) => element.hasAttribute('data-open')) - - expect(openBackdrops).toHaveLength(2) - }) - - it('should pass schema defaults to auth form', () => { - render( - , - ) - - expect(mockAuthFormProps?.defaultValues).toMatchObject({ - client_id: 'default-client-id', - }) - }) - - it('should call onClose when cancel clicked', () => { - const mockOnClose = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/i })) - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should close through controlled open state when cancel clicked', async () => { - const mockOnClose = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/i })) - - await waitFor(() => { - expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') - }) - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should stay open when backdrop is clicked', () => { - const mockOnClose = vi.fn() - render( - , - ) - - const backdrop = document.querySelector('.bg-background-overlay') - expect(backdrop).toBeInTheDocument() - - fireEvent.click(backdrop!) - - expect(screen.getByTestId('modal-open-state')).toHaveTextContent('true') - expect(mockOnClose).not.toHaveBeenCalled() - }) - - it('should save settings on save only button click', async () => { - const mockOnClose = vi.fn() - const mockOnUpdate = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'plugin.auth.saveOnly' })) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith( - expect.objectContaining({ - enable_oauth_custom_client: true, - }), - ) - }) - }) - - it('should ignore duplicate save clicks while action is pending', async () => { - const mockOnClose = vi.fn() - let resolveSave: (value: object) => void = () => {} - mockSetPluginOAuthCustomClient.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSave = resolve - }), - ) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'plugin.auth.saveOnly' })) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1) - }) - - fireEvent.click(screen.getByRole('button', { name: 'plugin.auth.saveOnly' })) - - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1) - - resolveSave({}) - - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalled() - }) - }) - - it('should request authorization after saving settings', async () => { - const user = userEvent.setup() - const mockOnRequestAuthorization = vi.fn().mockResolvedValue(undefined) - render( - , + + ) - - await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() - expect(mockOnRequestAuthorization).toHaveBeenCalledOnce() - }) - }) - - it('should not request authorization when saving settings fails', async () => { - const user = userEvent.setup() - const mockOnRequestAuthorization = vi.fn() - mockSetPluginOAuthCustomClient.mockRejectedValueOnce(new Error('Save failed')) - render( - , - ) - - await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) - - await waitFor(() => { - expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() - }) - expect(mockOnRequestAuthorization).not.toHaveBeenCalled() - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should remove custom client settings', async () => { - const mockOnClose = vi.fn() - const mockOnUpdate = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) - - await waitFor(() => { - expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled() - }) - expect(mockOnClose).toHaveBeenCalled() - expect(mockOnUpdate).toHaveBeenCalled() - expect(mockInvalidPluginOAuthClientSchema).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'common.api.actionSuccess', - type: 'success', - }), - ) - }) - - it('should render readme entrance when detail is provided', () => { - const payload = { ...basePayload, detail: { name: 'Test' } as never } - render() - - expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() - }) - - it('should stay open when clicking inside the modal from a popover', async () => { - const user = userEvent.setup() - const mockOnClose = vi.fn() - const mockOnPopoverClose = vi.fn() - - render( - , - ) - - const form = await screen.findByTestId('auth-form') - - await user.click(form) - - expect(mockOnClose).not.toHaveBeenCalled() - expect(mockOnPopoverClose).not.toHaveBeenCalled() - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) + } + render() + await user.type(await screen.findByRole('textbox', { name: /Client ID/ }), 'new-client') + expect(onClose).not.toHaveBeenCalled() + expect(onPopoverClose).not.toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx index 0f8a8d29892d65..78c600f178b501 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx @@ -102,21 +102,8 @@ vi.mock('@/service/use-triggers', () => ({ data: { options: [] }, isLoading: false, }), - useTriggerPluginDynamicOptionsInfo: () => ({ - data: null, - isLoading: false, - }), - useInvalidTriggerDynamicOptions: () => vi.fn(), })) -vi.mock('@/service/use-common', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useMembers: () => ({ data: { accounts: [] } }), - } -}) - // ==================== Test Utilities ==================== const createConsoleQueryClient = () => diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx index f91f15ee05e9f1..a4cb40038567b1 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx @@ -1,119 +1,87 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useState } from 'react' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' +import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app' import AppInputsPanel from '../app-inputs-panel' -let mockHookResult = { - inputFormSchema: [] as Array>, - isError: false, - isLoading: false, - retry: vi.fn(), -} - -vi.mock('@/app/components/base/loading-placeholder', () => ({ - LoadingPlaceholder: () =>
Loading
, -})) - -vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form', () => ({ - default: ({ onFormChange }: { onFormChange: (value: Record) => void }) => ( - - ), -})) - -vi.mock( - '@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema', - () => ({ - useAppInputsFormSchema: () => mockHookResult, - }), -) - -describe('AppInputsPanel', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHookResult = { - inputFormSchema: [], - isError: false, - isLoading: false, - retry: vi.fn(), - } - }) - - it('should render a loading state', () => { - mockHookResult = { - inputFormSchema: [], - isError: false, - isLoading: true, - retry: vi.fn(), - } - - render( - , - ) - - expect(screen.getByTestId('loading')).toBeInTheDocument() - }) - - it('should render an error state and retry the owned queries', async () => { - const user = userEvent.setup() - const retry = vi.fn() - mockHookResult = { - inputFormSchema: [], - isError: true, - isLoading: false, - retry, - } - - render( - , - ) - - expect(screen.getByRole('alert')).toHaveTextContent('common.errorBoundary.title') - await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) - - expect(retry).toHaveBeenCalledTimes(1) - }) - - it('should render an empty state when no inputs are available', () => { - render( - , - ) - - expect(screen.getByText('app.appSelector.noParams')).toBeInTheDocument() +const { getApp } = vi.hoisted(() => ({ getApp: vi.fn() })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + const { createFileUploadConfigFixture } = await import('@/test/fixtures/file-upload-config') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'files.upload.get': async () => createFileUploadConfigFixture(), + 'apps.byAppId.get': getApp, + }), + } +}) +const appDetail = { id: 'app-1', mode: 'completion' } as const +const appWithInputs = () => + createAppDetailFixture({ + id: 'app-1', + mode: 'completion', + model_config: createAppModelConfigFixture({ + user_input_form: [ + { 'text-input': { variable: 'topic', label: 'Topic', required: true } }, + { select: { variable: 'tone', label: 'Tone', options: ['Formal', 'Casual'] } }, + ], + }), }) - it('should render the inputs form and propagate changes', () => { - const onFormChange = vi.fn() - mockHookResult = { - inputFormSchema: [{ variable: 'topic' }], - isError: false, - isLoading: false, - retry: vi.fn(), - } - - render( +it('loads real input schemas and preserves the other field when inputs change', async () => { + const request = createDeferred() + getApp.mockReturnValue(request.promise) + const user = userEvent.setup() + const onChange = vi.fn() + const Harness = () => { + const [inputs, setInputs] = useState>({ + topic: 'Initial', + tone: 'Formal', + }) + return ( , + appDetail={appDetail} + value={{ app_id: 'app-1', inputs }} + onFormChange={(next) => { + setInputs(next) + onChange(next) + }} + /> ) + } + renderWithConsoleQuery() + expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument() + request.resolve(appWithInputs()) + const topic = await screen.findByRole('textbox', { name: 'Topic' }) + expect(topic).toHaveValue('Initial') + await user.clear(topic) + await user.type(topic, 'Updated') + await user.click(screen.getByRole('combobox', { name: 'Tone' })) + await user.click(await screen.findByRole('option', { name: 'Casual' })) + expect(onChange).toHaveBeenLastCalledWith({ topic: 'Updated', tone: 'Casual' }) + expect(topic).toHaveValue('Updated') + expect(getApp).toHaveBeenCalledWith({ params: { app_id: 'app-1' } }) +}) - fireEvent.click(screen.getByTestId('app-inputs-form')) +it('retries a failed app query and replaces the error with its actual fields', async () => { + getApp.mockRejectedValueOnce(new Error('Unavailable')).mockResolvedValue(appWithInputs()) + const user = userEvent.setup() + renderWithConsoleQuery() + expect(await screen.findByRole('alert')).toHaveTextContent('common.errorBoundary.title') + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(await screen.findByRole('textbox', { name: 'Topic' })).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(getApp).toHaveBeenCalledTimes(2) +}) - expect(onFormChange).toHaveBeenCalledWith({ topic: 'updated' }) - }) +it('shows no parameters when the app has no user input fields', async () => { + getApp.mockResolvedValue(createAppDetailFixture({ id: 'app-1', mode: 'completion' })) + renderWithConsoleQuery() + expect(await screen.findByText('app.appSelector.noParams')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx index 3069b579325d9c..452ba167b9a2ae 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react' import type { PluginDetail } from '@/app/components/plugins/types' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' import { createAccountProfileQueryClient } from '@/test/console/account-profile' @@ -24,19 +24,22 @@ vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.toolProviders.get': () => ({ - data: [ - { +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { createToolProviderFixture } = await import('@/test/console/tool-provider-fixture') + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.toolProviders.get': async () => [ + createToolProviderFixture({ name: 'tool-plugin/provider-a', type: 'builtin', allow_delete: true, - }, + }), ], }), - }) + } }) vi.mock('@/service/use-plugins', () => ({ @@ -62,14 +65,6 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({ ), })) -vi.mock('@/app/components/base/badges/verified', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/base/deprecation-notice', () => ({ - default: () =>
, -})) - vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ default: ({ src }: { src: string }) =>
{src}
, })) @@ -193,13 +188,15 @@ describe('DetailHeader', () => { vi.clearAllMocks() }) - it('renders the plugin summary, source badge, auth section, and modal container', () => { + it('renders the plugin summary, source badge, auth section, and modal container', async () => { render() expect(screen.getByTestId('title')).toHaveTextContent('Tool Plugin') expect(screen.getByTestId('description')).toHaveTextContent('Tool plugin description') expect(screen.getByTestId('source-badge')).toHaveTextContent('marketplace') - expect(screen.getByTestId('plugin-auth')).toHaveTextContent('tool-plugin/provider-a') + await waitFor(() => + expect(screen.getByTestId('plugin-auth')).toHaveTextContent('tool-plugin/provider-a'), + ) fireEvent.click( screen.getByRole('button', { name: 'plugin.detailPanel.operation.moreActions' }), ) diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx index 9ec9c0655b81ac..39cb24f453d3c9 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/llm-params-panel.spec.tsx @@ -2,8 +2,11 @@ import type { FormValue, ModelParameterRule, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' // Import component after mocks import LLMParamsPanel from '../llm-params-panel' @@ -11,7 +14,11 @@ import LLMParamsPanel from '../llm-params-panel' // All vi.mock() calls are hoisted, so inline all mock data // Mock useModelParameterRules hook -const mockUseModelParameterRules = vi.fn() +const getRules = vi.fn() +let queryClient = createConsoleQueryClient() +const rulesKey = + consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key() +const render = (ui: React.ReactElement) => renderWithConsoleQuery(ui, { queryClient }) // Mock config constants with inline data vi.mock('@/config', () => ({ @@ -110,44 +117,6 @@ vi.mock( }), ) -vi.mock( - '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', - () => ({ - getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => { - const toneConfigMap: Record | undefined> = { - 1: { - temperature: 0.8, - top_p: 0.9, - presence_penalty: 0.1, - frequency_penalty: 0.1, - }, - 2: { - temperature: 0.5, - top_p: 0.85, - presence_penalty: 0.2, - frequency_penalty: 0.3, - }, - 3: { - temperature: 0.2, - top_p: 0.75, - presence_penalty: 0.5, - frequency_penalty: 0.5, - }, - } - const toneConfig = toneConfigMap[toneId] - if (!toneConfig) return {} - - if (!supportedParameterNames) return toneConfig - - return Object.entries(toneConfig).reduce>((acc, [key, value]) => { - if (supportedParameterNames.includes(key)) acc[key] = value - - return acc - }, {}) - }, - }), -) - // Mock ParameterItem component vi.mock( '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item', @@ -228,49 +197,29 @@ const createDefaultProps = ( ...overrides, }) -/** - * Setup mock for useModelParameterRules - */ -const setupModelParameterRulesMock = ( - config: { - data?: ModelParameterRule[] - isPending?: boolean - isLoading?: boolean - } = {}, -) => { - mockUseModelParameterRules.mockReturnValue({ - data: config.data ? { data: config.data } : undefined, - isPending: config.isPending ?? false, - isLoading: config.isLoading ?? config.isPending ?? false, - }) +const setRules = (data: ModelParameterRule[] | undefined) => { + queryClient.setQueryDefaults(rulesKey, { initialData: data ? { data } : undefined }) + if (data) + act(() => { + queryClient.setQueriesData({ queryKey: rulesKey }, { data }) + }) } // ==================== Tests ==================== -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (options: { - queryKey: [unknown, { input?: { params: { provider: string }; query: { model: string } } }] - }) => - mockUseModelParameterRules( - options.queryKey[1].input?.params?.provider, - options.queryKey[1].input?.query?.model, - ), - }) -}) - describe('LLMParamsPanel', () => { beforeEach(() => { vi.clearAllMocks() - setupModelParameterRulesMock({ data: [], isPending: false }) + queryClient = createConsoleQueryClient() + getRules.mockReturnValue(new Promise(() => {})) + setRules([]) }) // ==================== Rendering Tests ==================== describe('Rendering', () => { it('should render loading state when isPending is true', () => { // Arrange - setupModelParameterRulesMock({ isPending: true }) + setRules(undefined) const props = createDefaultProps() // Act @@ -282,7 +231,7 @@ describe('LLMParamsPanel', () => { it('should not render loading state when model is not configured and parameter rules query is pending but disabled', () => { // Arrange - setupModelParameterRulesMock({ isPending: true, isLoading: false }) + setRules(undefined) const props = createDefaultProps({ provider: '', modelId: '' }) // Act @@ -295,7 +244,7 @@ describe('LLMParamsPanel', () => { it('should render parameters header', () => { // Arrange - setupModelParameterRulesMock({ data: [], isPending: false }) + setRules([]) const props = createDefaultProps() // Act @@ -307,10 +256,7 @@ describe('LLMParamsPanel', () => { it('should render PresetsParameter for openai provider', () => { // Arrange - setupModelParameterRulesMock({ - data: [createParameterRule({ name: 'temperature' })], - isPending: false, - }) + setRules([createParameterRule({ name: 'temperature' })]) const props = createDefaultProps({ provider: 'langgenius/openai/openai' }) // Act @@ -322,10 +268,7 @@ describe('LLMParamsPanel', () => { it('should render PresetsParameter for azure_openai provider', () => { // Arrange - setupModelParameterRulesMock({ - data: [createParameterRule({ name: 'temperature' })], - isPending: false, - }) + setRules([createParameterRule({ name: 'temperature' })]) const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' }) // Act @@ -337,10 +280,7 @@ describe('LLMParamsPanel', () => { it('should not render PresetsParameter when no visible parameter supports presets', () => { // Arrange - setupModelParameterRulesMock({ - data: [createParameterRule({ name: 'max_tokens', type: 'int' })], - isPending: false, - }) + setRules([createParameterRule({ name: 'max_tokens', type: 'int' })]) const props = createDefaultProps({ provider: 'langgenius/openai/openai' }) // Act @@ -352,7 +292,7 @@ describe('LLMParamsPanel', () => { it('should not render PresetsParameter for non-preset providers', () => { // Arrange - setupModelParameterRulesMock({ data: [], isPending: false }) + setRules([]) const props = createDefaultProps({ provider: 'anthropic/claude' }) // Act @@ -368,7 +308,7 @@ describe('LLMParamsPanel', () => { createParameterRule({ name: 'temperature' }), createParameterRule({ name: 'top_p', label: { en_US: 'Top P', zh_Hans: 'Top P' } }), ] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps() // Act @@ -381,7 +321,7 @@ describe('LLMParamsPanel', () => { it('should not render parameter items when rules are empty', () => { // Arrange - setupModelParameterRulesMock({ data: [], isPending: false }) + setRules([]) const props = createDefaultProps() // Act @@ -394,7 +334,7 @@ describe('LLMParamsPanel', () => { it('should include stop parameter rule in advanced mode', () => { // Arrange const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ isAdvancedMode: true }) // Act @@ -408,7 +348,7 @@ describe('LLMParamsPanel', () => { it('should not include stop parameter rule in non-advanced mode', () => { // Arrange const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ isAdvancedMode: false }) // Act @@ -422,7 +362,7 @@ describe('LLMParamsPanel', () => { it('should pass isInWorkflow=true to ParameterItem', () => { // Arrange const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps() // Act @@ -438,7 +378,8 @@ describe('LLMParamsPanel', () => { // ==================== Props Testing ==================== describe('Props', () => { - it('should call useModelParameterRules with provider and modelId', () => { + it('should call useModelParameterRules with provider and modelId', async () => { + setRules(undefined) // Arrange const props = createDefaultProps({ provider: 'test-provider', @@ -449,13 +390,18 @@ describe('LLMParamsPanel', () => { render() // Assert - expect(mockUseModelParameterRules).toHaveBeenCalledWith('test-provider', 'test-model') + await waitFor(() => + expect(getRules).toHaveBeenCalledWith({ + params: { provider: 'test-provider' }, + query: { model: 'test-model' }, + }), + ) }) it('should pass completion params value to ParameterItem', () => { // Arrange const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ completionParams: { temperature: 0.8 }, }) @@ -470,7 +416,7 @@ describe('LLMParamsPanel', () => { it('should handle undefined completion params value', () => { // Arrange const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ completionParams: {}, }) @@ -489,15 +435,12 @@ describe('LLMParamsPanel', () => { it('should apply Creative preset config', () => { // Arrange const onCompletionParamsChange = vi.fn() - setupModelParameterRulesMock({ - data: [ - createParameterRule({ name: 'temperature' }), - createParameterRule({ name: 'top_p' }), - createParameterRule({ name: 'presence_penalty' }), - createParameterRule({ name: 'frequency_penalty' }), - ], - isPending: false, - }) + setRules([ + createParameterRule({ name: 'temperature' }), + createParameterRule({ name: 'top_p' }), + createParameterRule({ name: 'presence_penalty' }), + createParameterRule({ name: 'frequency_penalty' }), + ]) const props = createDefaultProps({ provider: 'langgenius/openai/openai', onCompletionParamsChange, @@ -521,15 +464,12 @@ describe('LLMParamsPanel', () => { it('should apply Balanced preset config', () => { // Arrange const onCompletionParamsChange = vi.fn() - setupModelParameterRulesMock({ - data: [ - createParameterRule({ name: 'temperature' }), - createParameterRule({ name: 'top_p' }), - createParameterRule({ name: 'presence_penalty' }), - createParameterRule({ name: 'frequency_penalty' }), - ], - isPending: false, - }) + setRules([ + createParameterRule({ name: 'temperature' }), + createParameterRule({ name: 'top_p' }), + createParameterRule({ name: 'presence_penalty' }), + createParameterRule({ name: 'frequency_penalty' }), + ]) const props = createDefaultProps({ provider: 'langgenius/openai/openai', onCompletionParamsChange, @@ -552,15 +492,12 @@ describe('LLMParamsPanel', () => { it('should apply Precise preset config', () => { // Arrange const onCompletionParamsChange = vi.fn() - setupModelParameterRulesMock({ - data: [ - createParameterRule({ name: 'temperature' }), - createParameterRule({ name: 'top_p' }), - createParameterRule({ name: 'presence_penalty' }), - createParameterRule({ name: 'frequency_penalty' }), - ], - isPending: false, - }) + setRules([ + createParameterRule({ name: 'temperature' }), + createParameterRule({ name: 'top_p' }), + createParameterRule({ name: 'presence_penalty' }), + createParameterRule({ name: 'frequency_penalty' }), + ]) const props = createDefaultProps({ provider: 'langgenius/openai/openai', onCompletionParamsChange, @@ -583,10 +520,7 @@ describe('LLMParamsPanel', () => { it('should apply empty config for Custom preset (spreads undefined)', () => { // Arrange const onCompletionParamsChange = vi.fn() - setupModelParameterRulesMock({ - data: [createParameterRule({ name: 'temperature' })], - isPending: false, - }) + setRules([createParameterRule({ name: 'temperature' })]) const props = createDefaultProps({ provider: 'langgenius/openai/openai', onCompletionParamsChange, @@ -604,10 +538,7 @@ describe('LLMParamsPanel', () => { it('should apply only preset config keys supported by visible parameters', () => { // Arrange const onCompletionParamsChange = vi.fn() - setupModelParameterRulesMock({ - data: [createParameterRule({ name: 'temperature' })], - isPending: false, - }) + setRules([createParameterRule({ name: 'temperature' })]) const props = createDefaultProps({ provider: 'langgenius/openai/openai', onCompletionParamsChange, @@ -631,7 +562,7 @@ describe('LLMParamsPanel', () => { // Arrange const onCompletionParamsChange = vi.fn() const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ onCompletionParamsChange, completionParams: { existing: 'value' }, @@ -652,7 +583,7 @@ describe('LLMParamsPanel', () => { // Arrange const onCompletionParamsChange = vi.fn() const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ onCompletionParamsChange, completionParams: { temperature: 0.9 }, @@ -674,7 +605,7 @@ describe('LLMParamsPanel', () => { // Arrange const onCompletionParamsChange = vi.fn() const rules = [createParameterRule({ name: 'temperature', default: 0.7 })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ onCompletionParamsChange, completionParams: { existing: 'value' }, @@ -695,7 +626,7 @@ describe('LLMParamsPanel', () => { // Arrange const onCompletionParamsChange = vi.fn() const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ onCompletionParamsChange, completionParams: { temperature: 0.8, other: 'value' }, @@ -715,12 +646,9 @@ describe('LLMParamsPanel', () => { // ==================== Memoization ==================== describe('Memoization - parameterRules', () => { - it('should return empty array when data is undefined', () => { + it('should render no parameter items for empty rules', () => { // Arrange - mockUseModelParameterRules.mockReturnValue({ - data: undefined, - isPending: false, - }) + setRules([]) const props = createDefaultProps() // Act @@ -730,28 +658,13 @@ describe('LLMParamsPanel', () => { expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument() }) - it('should return empty array when data.data is undefined', () => { - // Arrange - mockUseModelParameterRules.mockReturnValue({ - data: { data: undefined }, - isPending: false, - }) - const props = createDefaultProps() - - // Act - render() - - // Assert - expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument() - }) - it('should use data.data when available', () => { // Arrange const rules = [ createParameterRule({ name: 'temperature' }), createParameterRule({ name: 'top_p' }), ] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps() // Act @@ -768,7 +681,7 @@ describe('LLMParamsPanel', () => { it('should handle empty completionParams', () => { // Arrange const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ completionParams: {} }) // Act @@ -786,7 +699,7 @@ describe('LLMParamsPanel', () => { createParameterRule({ name: 'max_tokens', type: 'int' }), createParameterRule({ name: 'presence_penalty' }), ] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps() // Act @@ -805,7 +718,7 @@ describe('LLMParamsPanel', () => { createParameterRule({ name: 'temperature' }), createParameterRule({ name: 'top_p' }), ] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ modelId: 'gpt-4' }) // Act @@ -822,7 +735,7 @@ describe('LLMParamsPanel', () => { it('should update parameter items when rules change', () => { // Arrange const initialRules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: initialRules, isPending: false }) + setRules(initialRules) const props = createDefaultProps() // Act @@ -835,7 +748,7 @@ describe('LLMParamsPanel', () => { createParameterRule({ name: 'temperature' }), createParameterRule({ name: 'top_p' }), ] - setupModelParameterRulesMock({ data: newRules, isPending: false }) + setRules(newRules) rerender() // Assert @@ -843,28 +756,26 @@ describe('LLMParamsPanel', () => { expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument() }) - it('should show loading when transitioning from loaded to loading', () => { - // Arrange + it('loads rules when switching to an uncached model', async () => { const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps() - - // Act const { rerender } = render() expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument() - - // Update to loading - setupModelParameterRulesMock({ isPending: true }) - rerender() - - // Assert - Loading component uses role="progressbar" with aria-label + setRules(undefined) + const response = createDeferred<{ data: ModelParameterRule[] }>() + getRules.mockReturnValue(response.promise) + rerender() expect(screen.getByRole('progressbar')).toBeInTheDocument() + await act(async () => response.resolve({ data: [createParameterRule({ name: 'top_p' })] })) + expect(await screen.findByTestId('parameter-item-top_p')).toBeInTheDocument() + expect(screen.queryByTestId('parameter-item-temperature')).not.toBeInTheDocument() }) it('should update when isAdvancedMode changes', () => { // Arrange const rules = [createParameterRule({ name: 'temperature' })] - setupModelParameterRulesMock({ data: rules, isPending: false }) + setRules(rules) const props = createDefaultProps({ isAdvancedMode: false }) // Act @@ -878,3 +789,15 @@ describe('LLMParamsPanel', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (input) => + getRules(input), + }), + } +}) diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx index b1461060777209..022bbb6aaf8e78 100644 --- a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/__tests__/index.spec.tsx @@ -14,13 +14,15 @@ import MultipleToolSelector from '../index' // Mock useAllMCPTools hook const mockMCPToolsData = vi.fn<() => ToolWithProvider[] | undefined>(() => undefined) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.tools.mcp.get': () => ({ - data: mockMCPToolsData(), +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.tools.mcp.get': async () => mockMCPToolsData() ?? new Promise(() => {}), }), - }) + } }) // Track edit tool index for unique test IDs @@ -340,7 +342,7 @@ describe('MultipleToolSelector', () => { ).not.toBeInTheDocument() }) - it('should render enabled count when tools are selected', () => { + it('should render enabled count when tools are selected', async () => { // Arrange const tools = [ createToolValue({ tool_name: 'tool-1', enabled: true }), @@ -351,7 +353,7 @@ describe('MultipleToolSelector', () => { renderComponent({ value: tools }) // Assert - expect(screen.getByText('1/2')).toBeInTheDocument() + expect(await screen.findByText('1/2')).toBeInTheDocument() expect(screen.getByText('appDebug.agent.tools.enabled')).toBeInTheDocument() }) }) @@ -449,7 +451,7 @@ describe('MultipleToolSelector', () => { expect(screen.getByText('2/2')).toBeInTheDocument() }) - it('should not count MCP tools when version is unsupported', () => { + it('should not count MCP tools when version is unsupported', async () => { // Arrange const mcpTools = [createMCPTool({ id: 'mcp-provider' })] mockMCPToolsData.mockReturnValue(mcpTools) @@ -463,7 +465,7 @@ describe('MultipleToolSelector', () => { renderComponent({ value: tools, versionSupported: false }) // Assert - expect(screen.getByText('1/2')).toBeInTheDocument() + expect(await screen.findByText('1/2')).toBeInTheDocument() }) it('should manage open state for add tool panel', () => { @@ -768,7 +770,7 @@ describe('MultipleToolSelector', () => { expect(screen.getByText('2/2')).toBeInTheDocument() }) - it('should exclude MCP tools from enabled count when strategy version is unsupported', () => { + it('should exclude MCP tools from enabled count when strategy version is unsupported', async () => { // Arrange const mcpTools = [createMCPTool({ id: 'mcp-provider' })] mockMCPToolsData.mockReturnValue(mcpTools) @@ -782,7 +784,7 @@ describe('MultipleToolSelector', () => { renderComponent({ value: tools, versionSupported: false }) // Assert - Only regular tool should be counted - expect(screen.getByText('1/2')).toBeInTheDocument() + expect(await screen.findByText('1/2')).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx index 876f45935b2bcc..c923fd3dae28c1 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx @@ -1,140 +1,76 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { toast } from '@/app/notifications' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { DeleteConfirm } from '../delete-confirm' -const mockRefetch = vi.fn() -const mockDelete = vi.fn() -const mockDeleteState = { isPending: false } -const mockToastSuccess = vi.hoisted(() => vi.fn()) -const mockToastError = vi.hoisted(() => vi.fn()) - +const requests = vi.hoisted(() => ({ remove: vi.fn(), refetch: vi.fn() })) vi.mock('../use-subscription-list', () => ({ - useSubscriptionList: () => ({ refetch: mockRefetch }), -})) - -vi.mock('@/service/use-triggers', () => ({ - useDeleteTriggerSubscription: () => ({ - mutate: mockDelete, - isPending: mockDeleteState.isPending, - }), + useSubscriptionList: () => ({ refetch: requests.refetch }), })) - -vi.mock('@/app/notifications', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') return { ...actual, - toast: { - ...actual.toast, - success: mockToastSuccess, - error: mockToastError, - }, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.triggerProvider.bySubscriptionId.subscriptions.delete.post': + requests.remove, + }), } }) - -beforeEach(() => { - vi.clearAllMocks() - mockDeleteState.isPending = false - mockDelete.mockImplementation((_id: string, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }) +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn() } })) +const confirmName = 'pluginTrigger.subscription.list.item.actions.deleteConfirm.confirm' +beforeEach(() => requests.remove.mockResolvedValue({})) + +it('requires the subscription name and prevents repeated submission while deletion is pending', async () => { + const user = userEvent.setup() + const request = createDeferred() + requests.remove.mockReturnValue(request.promise) + const onClose = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: confirmName })) + expect(requests.remove).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith( + 'pluginTrigger.subscription.list.item.actions.deleteConfirm.confirmInputWarning', + ) + const input = screen.getByRole('textbox', { name: /confirmInputTip/ }) + await user.type(input, 'Subscription One{Enter}') + await waitFor(() => expect(requests.remove).toHaveBeenCalledOnce()) + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled() + await user.type(input, '{Enter}') + expect(requests.remove).toHaveBeenCalledExactlyOnceWith({ params: { subscription_id: 'sub-1' } }) + expect(onClose).not.toHaveBeenCalled() + request.resolve({}) + await waitFor(() => expect(onClose).toHaveBeenCalledExactlyOnceWith(true)) + expect(requests.refetch).toHaveBeenCalledOnce() }) -describe('DeleteConfirm', () => { - it('should prevent deletion when workflows in use and input mismatch', () => { - render( - , - ) - - fireEvent.click( - screen.getByRole('button', { - name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/, - }), - ) - - expect(mockDelete).not.toHaveBeenCalled() - expect(mockToastError).toHaveBeenCalledWith( - 'pluginTrigger.subscription.list.item.actions.deleteConfirm.confirmInputWarning', - ) - }) - - it('should allow deletion by submitting a matching input name', async () => { - const onClose = vi.fn() - const user = userEvent.setup() - - render( - , - ) - - await user.type( - screen.getByRole('textbox', { - name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirmInputTip/, - }), - 'Subscription One{Enter}', - ) - - expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object)) - expect(mockRefetch).toHaveBeenCalledTimes(1) - expect(onClose).toHaveBeenCalledWith(true) - }) - - it('should show error toast when delete fails', () => { - mockDelete.mockImplementation((_id: string, options?: { onError?: (error: Error) => void }) => { - options?.onError?.(new Error('network error')) - }) - - render( - , - ) - - fireEvent.click( - screen.getByRole('button', { - name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/, - }), - ) - - expect(mockToastError).toHaveBeenCalledWith('network error') - }) - - it('should ignore form submission while deletion is pending', async () => { - mockDeleteState.isPending = true - const user = userEvent.setup() - - render( - , - ) - - await user.type( - screen.getByRole('textbox', { - name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirmInputTip/, - }), - 'Subscription One{Enter}', - ) - - expect(mockDelete).not.toHaveBeenCalled() - }) +it('reports a failed deletion and allows retry without closing', async () => { + const user = userEvent.setup() + requests.remove.mockRejectedValueOnce(new Error('network error')) + const onClose = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: confirmName })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('network error')) + expect(onClose).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: confirmName })) + await waitFor(() => expect(onClose).toHaveBeenCalledExactlyOnceWith(true)) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx index 7e5095d113b1b6..0ffad6ea978bad 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx @@ -24,8 +24,6 @@ let mockSubscriptionListState: { subscriptions?: TriggerSubscription[] } -let mockPluginDetail: PluginDetail | undefined - vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => { if (mockSubscriptionListError) throw mockSubscriptionListError @@ -33,12 +31,6 @@ vi.mock('../use-subscription-list', () => ({ }, })) -vi.mock('../../../store', () => ({ - usePluginStore: ( - selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined, - ) => selector({ detail: mockPluginDetail }), -})) - const mockInitiateOAuth = vi.fn() const mockDeleteSubscription = vi.fn() @@ -89,7 +81,6 @@ beforeEach(() => { vi.clearAllMocks() mockRefetch.mockReset() mockSubscriptionListError = null - mockPluginDetail = undefined mockSubscriptionListState = { isLoading: false, refetch: mockRefetch, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx index 0163175b42904a..be6a7ffd70d987 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/list-view.spec.tsx @@ -10,10 +10,6 @@ vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions }), })) -vi.mock('../../../store', () => ({ - usePluginStore: () => ({ detail: undefined }), -})) - vi.mock('@/service/use-triggers', () => ({ useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }), useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx index 91dffd4e5fc5c4..02e7b2ecd3f6fa 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx @@ -16,10 +16,6 @@ vi.mock('../use-subscription-list', () => ({ }), })) -vi.mock('../../../store', () => ({ - usePluginStore: () => ({ detail: undefined }), -})) - vi.mock('@/service/use-triggers', () => ({ useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }), useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx index 0d9580e5bb3de9..58000b930ee52e 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx @@ -1,30 +1,36 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { SubscriptionSelectorView } from '../selector-view' let mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() -const mockDelete = vi.fn((_: string, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() -}) +const { mockDelete } = vi.hoisted(() => ({ mockDelete: vi.fn().mockResolvedValue({}) })) vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch }), })) -vi.mock('../../../store', () => ({ - usePluginStore: () => ({ detail: undefined }), -})) - -vi.mock('@/service/use-triggers', () => ({ +vi.mock('@/service/use-triggers', async (importOriginal) => ({ + ...(await importOriginal()), useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }), useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }), useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }), - useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }), })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.triggerProvider.bySubscriptionId.subscriptions.delete.post': mockDelete, + }), + } +}) + vi.mock('@/app/notifications', async (importOriginal) => ({ ...(await importOriginal()), toast: Object.assign(vi.fn(), { @@ -76,21 +82,6 @@ describe('SubscriptionSelectorView', () => { ) }) - it('should distinguish selected vs unselected subscription row', () => { - const { rerender } = render() - - const getRowClassName = () => - screen.getByRole('button', { name: 'Subscription One' }).closest('div')?.className ?? '' - - const selectedClassName = getRowClassName() - - rerender() - - const unselectedClassName = getRowClassName() - - expect(selectedClassName).not.toBe(unselectedClassName) - }) - it('should omit header when there are no subscriptions', () => { mockSubscriptions = [] @@ -111,7 +102,7 @@ describe('SubscriptionSelectorView', () => { ).toBeInTheDocument() }) - it('should request selection reset after confirming delete', () => { + it('should request selection reset after confirming delete', async () => { const onSelect = vi.fn() const { container } = render() @@ -124,8 +115,8 @@ describe('SubscriptionSelectorView', () => { }), ) - expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object)) - expect(onSelect).toHaveBeenCalledWith({ id: '', name: '' }) + await waitFor(() => expect(onSelect).toHaveBeenCalledWith({ id: '', name: '' })) + expect(mockDelete).toHaveBeenCalledWith({ params: { subscription_id: 'sub-1' } }) }) it('should close delete confirm without selection reset on cancel', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx index 2105a12a36f3b0..be52992c01a640 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/subscription-card.spec.tsx @@ -10,21 +10,6 @@ vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), })) -vi.mock('../../../store', () => ({ - usePluginStore: () => ({ - detail: { - id: 'detail-1', - plugin_id: 'plugin-1', - name: 'Plugin', - plugin_unique_identifier: 'plugin-uid', - provider: 'provider-1', - declaration: { - trigger: { subscription_constructor: { parameters: [], credentials_schema: [] } }, - }, - }, - }), -})) - vi.mock('@/service/use-triggers', () => ({ useUpdateTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), useVerifyTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index e09bd52ce7258c..430c82054c46bb 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -1,2260 +1,203 @@ -import type * as React from 'react' import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FormTypeEnum } from '@/app/components/base/form/types' import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types' +import { toast } from '@/app/notifications' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { CommonCreateModal } from '../common-modal' -type PluginDetail = { - plugin_id: string - provider: string - name: string - declaration?: { - trigger?: { - subscription_schema?: Array<{ - name: string - type: string - required?: boolean - description?: string - }> - subscription_constructor?: { - credentials_schema?: Array<{ - name: string - type: string - required?: boolean - help?: string - }> - parameters?: Array<{ name: string; type: string; required?: boolean; description?: string }> - } - } - } -} - -type TriggerLogEntity = { - id: string - message: string - timestamp: string - level: 'info' | 'warn' | 'error' -} - -function createMockPluginDetail(overrides: Partial = {}): PluginDetail { +const requests = vi.hoisted(() => ({ + create: vi.fn(), + verify: vi.fn(), + build: vi.fn(), + update: vi.fn(), + refetch: vi.fn(), +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + const prefix = 'workspaces.current.triggerProvider.byProvider.subscriptions.builder' return { - plugin_id: 'test-plugin-id', - provider: 'test-provider', - name: 'Test Plugin', + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + [`${prefix}.create.post`]: requests.create, + [`${prefix}.verifyAndUpdate.bySubscriptionBuilderId.post`]: requests.verify, + [`${prefix}.build.bySubscriptionBuilderId.post`]: requests.build, + [`${prefix}.update.bySubscriptionBuilderId.post`]: requests.update, + [`${prefix}.logs.bySubscriptionBuilderId.get`]: async () => ({ logs: [] }), + }), + } +}) +vi.mock('../../../store', () => ({ + usePluginStore: () => ({ + plugin_id: 'test/plugin', + provider: 'provider-a', + name: 'Plugin', declaration: { trigger: { - subscription_schema: [], + subscription_schema: [ + { name: 'channel', label: 'Channel', type: FormTypeEnum.textInput, required: true }, + ], subscription_constructor: { - credentials_schema: [], - parameters: [], + credentials_schema: [ + { name: 'api_key', label: 'API key', type: FormTypeEnum.textInput, required: true }, + ], + parameters: [ + { name: 'repo', label: 'Repository', type: FormTypeEnum.textInput, required: true }, + ], }, }, }, - ...overrides, - } -} - -function createMockSubscriptionBuilder( - overrides: Partial = {}, -): TriggerSubscriptionBuilder { - return { - id: 'builder-123', - name: 'Test Builder', - provider: 'test-provider', - credential_type: TriggerCredentialType.ApiKey, - credentials: {}, - endpoint: 'https://example.com/callback', - parameters: {}, - properties: {}, - workflows_in_use: 0, - ...overrides, - } -} - -function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEntity[] } { - return { logs } -} - -const mockPluginDetail = createMockPluginDetail() -const mockUsePluginStore = vi.fn(() => mockPluginDetail) -vi.mock('../../../store', () => ({ - usePluginStore: () => mockUsePluginStore(), + }), })) - -const mockRefetch = vi.fn() +// The list refresh is an external effect of completing this modal. vi.mock('../../use-subscription-list', () => ({ - useSubscriptionList: () => ({ - refetch: mockRefetch, - }), + useSubscriptionList: () => ({ refetch: requests.refetch }), })) - -const mockVerifyCredentials = vi.fn() -const mockCreateBuilder = vi.fn() -const mockBuildSubscription = vi.fn() -const mockUpdateBuilder = vi.fn() - -let mockIsVerifyingCredentials = false -let mockIsBuilding = false -const setMockPendingStates = (verifying: boolean, building: boolean) => { - mockIsVerifyingCredentials = verifying - mockIsBuilding = building +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +const builder: TriggerSubscriptionBuilder = { + id: 'builder-1', + provider: 'provider-a', + name: '', + credential_type: TriggerCredentialType.ApiKey, + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + workflows_in_use: 0, } - -vi.mock('@/service/use-triggers', () => ({ - useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ - mutate: mockVerifyCredentials, - get isPending() { - return mockIsVerifyingCredentials - }, - }), - useCreateTriggerSubscriptionBuilder: () => ({ - mutateAsync: mockCreateBuilder, - isPending: false, - }), - useBuildTriggerSubscription: () => ({ - mutate: mockBuildSubscription, - get isPending() { - return mockIsBuilding +const verifyName = 'pluginTrigger.modal.common.verify' +const createName = 'pluginTrigger.modal.common.create' +const subscriptionName = /pluginTrigger.modal.form.subscriptionName.label/ + +beforeEach(() => { + requests.create.mockResolvedValue({ subscription_builder: builder }) + requests.verify.mockResolvedValue({ verified: true }) + requests.build.mockResolvedValue({}) + requests.update.mockResolvedValue(builder) +}) +afterEach(() => vi.useRealTimers()) + +it('initializes a builder, verifies entered credentials and submits validated configuration', async () => { + const user = userEvent.setup() + const verification = createDeferred<{ verified: boolean }>() + requests.verify.mockReturnValue(verification.promise) + const onClose = vi.fn() + renderWithConsoleQuery( + , + ) + await waitFor(() => + expect(requests.create).toHaveBeenCalledWith({ + params: { provider: 'provider-a' }, + body: { credential_type: TriggerCredentialType.ApiKey }, + }), + ) + await user.type(screen.getByRole('textbox', { name: /API key/ }), 'secret') + await user.click(screen.getByRole('button', { name: verifyName })) + expect(requests.verify).toHaveBeenCalledWith( + { + params: { provider: 'provider-a', subscription_builder_id: 'builder-1' }, + body: { credentials: { api_key: 'secret' } }, }, - }), - useUpdateTriggerSubscriptionBuilder: () => ({ - mutate: mockUpdateBuilder, - isPending: false, - }), - useTriggerSubscriptionBuilderLogs: () => ({ - data: createMockLogData(), - }), -})) - -const mockParsePluginErrorMessage = vi.fn().mockReturnValue(null) -vi.mock('@/utils/error-parser', () => ({ - parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args), -})) - -vi.mock('@/utils/urlValidation', () => ({ - isPrivateOrLocalAddress: vi.fn().mockReturnValue(false), -})) - -const mockToastNotify = vi.fn() -vi.mock('@/app/notifications', () => ({ - toast: Object.assign((params: unknown) => mockToastNotify(params), { - success: (message: unknown) => mockToastNotify({ type: 'success', message }), - error: (message: unknown) => mockToastNotify({ type: 'error', message }), - warning: (message: unknown) => mockToastNotify({ type: 'warning', message }), - info: (message: unknown) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }), -})) - -type MockFormValuesConfig = { - values: Record - isCheckValidated: boolean -} -let mockFormValuesConfig: MockFormValuesConfig = { - values: { api_key: 'test-api-key', subscription_name: 'Test Subscription' }, - isCheckValidated: true, -} -let mockGetFormReturnsNull = false - -let mockSubscriptionFormValidated = true -let mockAutoParamsFormValidated = true -let mockManualPropsFormValidated = true - -const setMockFormValuesConfig = (config: MockFormValuesConfig) => { - mockFormValuesConfig = config -} -const setMockGetFormReturnsNull = (value: boolean) => { - mockGetFormReturnsNull = value -} -const setMockFormValidation = ( - subscription: boolean, - autoParams: boolean, - manualProps: boolean, -) => { - mockSubscriptionFormValidated = subscription - mockAutoParamsFormValidated = autoParams - mockManualPropsFormValidated = manualProps -} - -vi.mock('@/app/components/base/form/components/base', async () => { - const React = await import('react') - - type MockFormRef = { - getFormValues: (options: Record) => { - values: Record - isCheckValidated: boolean - } - setFields: (fields: Array<{ name: string; errors?: string[]; warnings?: string[] }>) => void - getForm: () => { setFieldValue: (name: string, value: unknown) => void } | null - } - type MockBaseFormProps = { formSchemas: Array<{ name: string }>; onChange?: () => void } - - function MockBaseFormInner( - { formSchemas, onChange }: MockBaseFormProps, - ref: React.ForwardedRef, - ) { - const isSubscriptionForm = formSchemas.some( - (s: { name: string }) => s.name === 'subscription_name', - ) - const isAutoParamsForm = formSchemas.some((s: { name: string }) => - [ - 'repo_name', - 'branch', - 'repo', - 'text_field', - 'dynamic_field', - 'bool_field', - 'text_input_field', - 'unknown_field', - 'count', - ].includes(s.name), - ) - const isManualPropsForm = formSchemas.some((s: { name: string }) => s.name === 'webhook_url') - - React.useImperativeHandle(ref, () => ({ - getFormValues: () => { - let isValidated = mockFormValuesConfig.isCheckValidated - if (isSubscriptionForm) isValidated = mockSubscriptionFormValidated - else if (isAutoParamsForm) isValidated = mockAutoParamsFormValidated - else if (isManualPropsForm) isValidated = mockManualPropsFormValidated - - return { - ...mockFormValuesConfig, - isCheckValidated: isValidated, - } - }, - setFields: () => {}, - getForm: () => (mockGetFormReturnsNull ? null : { setFieldValue: () => {} }), - })) - return ( -
- {formSchemas.map((schema: { name: string }) => ( - - ))} -
- ) - } - - return { - BaseForm: React.forwardRef(MockBaseFormInner), - } + { context: { silent: true } }, + ) + expect( + await screen.findByRole('button', { name: 'pluginTrigger.modal.common.verifying' }), + ).toBeDisabled() + expect(screen.queryByRole('textbox', { name: subscriptionName })).not.toBeInTheDocument() + verification.resolve({ verified: true }) + await screen.findByRole('textbox', { name: subscriptionName }) + expect( + screen.getByRole('textbox', { name: /pluginTrigger.modal.form.callbackUrl.label/ }), + ).toHaveValue(builder.endpoint) + await user.click(screen.getByRole('button', { name: createName })) + expect(requests.build).not.toHaveBeenCalled() + await user.type(screen.getByRole('textbox', { name: subscriptionName }), 'Repo updates') + await user.click(screen.getByRole('button', { name: createName })) + expect(requests.build).not.toHaveBeenCalled() + await user.type(screen.getByRole('textbox', { name: /Repository/ }), 'dify') + await user.click(screen.getByRole('button', { name: createName })) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(requests.build).toHaveBeenCalledExactlyOnceWith({ + params: { provider: 'provider-a', subscription_builder_id: 'builder-1' }, + body: { name: 'Repo updates', parameters: { repo: 'dify' } }, + }) + expect(requests.refetch).toHaveBeenCalledOnce() }) -vi.mock('@/app/components/base/encrypted-bottom', () => ({ - EncryptedBottom: () =>
Encrypted
, - EncryptedFooter: ({ children }: { children: React.ReactNode }) => <>{children}, -})) - -vi.mock('../../log-viewer', () => ({ - default: ({ logs }: { logs: TriggerLogEntity[] }) => ( -
- {logs.map((log) => ( -
- {log.message} -
- ))} -
- ), -})) - -vi.mock('es-toolkit/compat', () => ({ - debounce: (fn: (...args: unknown[]) => unknown) => { - const debouncedFn = (...args: unknown[]) => fn(...args) - debouncedFn.cancel = vi.fn() - return debouncedFn - }, -})) - -describe('CommonCreateModal', () => { - const defaultProps = { - onClose: vi.fn(), - createType: SupportedCreationMethods.APIKEY, - builder: undefined as TriggerSubscriptionBuilder | undefined, - } - - beforeEach(() => { - vi.clearAllMocks() - mockUsePluginStore.mockReturnValue(mockPluginDetail) - mockCreateBuilder.mockResolvedValue({ - subscription_builder: createMockSubscriptionBuilder(), - }) - // Reset configurable mocks - setMockPendingStates(false, false) - setMockFormValuesConfig({ - values: { api_key: 'test-api-key', subscription_name: 'Test Subscription' }, - isCheckValidated: true, - }) - setMockGetFormReturnsNull(false) - setMockFormValidation(true, true, true) // All forms validated by default - mockParsePluginErrorMessage.mockReturnValue(null) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render modal with correct title for API Key method', () => { - render() - - expect( - screen.getByRole('heading', { name: 'pluginTrigger.modal.apiKey.title' }), - ).toBeInTheDocument() - }) - - it('should render modal with correct title for Manual method', () => { - render() - - expect( - screen.getByRole('heading', { name: 'pluginTrigger.modal.manual.title' }), - ).toBeInTheDocument() - }) - - it('should render modal with correct title for OAuth method', () => { - render() - - expect( - screen.getByRole('heading', { name: 'pluginTrigger.modal.oauth.title' }), - ).toBeInTheDocument() - }) - - it('should show multi-steps for API Key method', () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - render() - - expect(screen.getByText('pluginTrigger.modal.steps.verify')).toBeInTheDocument() - expect(screen.getByText('pluginTrigger.modal.steps.configuration')).toBeInTheDocument() - }) - - it('should render LogViewer for Manual method', () => { - render() - - expect(screen.getByTestId('log-viewer')).toBeInTheDocument() - }) - }) - - describe('Builder Initialization', () => { - it('should create builder on mount when no builder provided', async () => { - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalledWith({ - provider: 'test-provider', - credential_type: 'api-key', - }) - }) - }) - - it('should not create builder when builder is provided', async () => { - const existingBuilder = createMockSubscriptionBuilder() - render() - - await waitFor(() => { - expect(mockCreateBuilder).not.toHaveBeenCalled() - }) - }) - - it('should show error toast when builder creation fails', async () => { - mockCreateBuilder.mockRejectedValueOnce(new Error('Creation failed')) - - render() - - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'pluginTrigger.modal.errors.createFailed', - }) - }) - }) - }) - - describe('API Key Flow', () => { - it('should start at Verify step for API Key method', () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - render() - - expect(screen.getByTestId('form-field-api_key')).toBeInTheDocument() - }) - - it('should show verify button text initially', () => { - render() - - expect(screen.getByTestId('modal-confirm')).toHaveTextContent( - 'pluginTrigger.modal.common.verify', - ) - }) - }) - - describe('Modal Actions', () => { - it('should call onClose when close button is clicked', () => { - const mockOnClose = vi.fn() - render() - - fireEvent.click(screen.getByRole('button', { name: /Close|operation.close/ })) - - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should call onConfirm handler when confirm button is clicked', () => { - // Provide builder so the guard passes and credentials check is reached - render() - - fireEvent.click(screen.getByTestId('modal-confirm')) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Please fill in all required credentials', - }) - }) - }) - - describe('Manual Method', () => { - it('should start at Configuration step for Manual method', () => { - render() - - expect(screen.getByText('pluginTrigger.modal.manual.logs.title')).toBeInTheDocument() - }) - - it('should render manual properties form when schema exists', () => { - const detailWithManualSchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithManualSchema) - - render() - - expect(screen.getByTestId('form-field-webhook_url')).toBeInTheDocument() - }) - - it('should show create button text for Manual method', () => { - render() - - expect(screen.getByTestId('modal-confirm')).toHaveTextContent( - 'pluginTrigger.modal.common.create', - ) - }) - }) - - describe('Form Interactions', () => { - it('should render credentials form fields', () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [ - { name: 'client_id', type: 'text', required: true }, - { name: 'client_secret', type: 'secret', required: true }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - render() - - expect(screen.getByTestId('form-field-client_id')).toBeInTheDocument() - expect(screen.getByTestId('form-field-client_secret')).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle missing provider gracefully', async () => { - const detailWithoutProvider = { ...mockPluginDetail, provider: '' } - mockUsePluginStore.mockReturnValue(detailWithoutProvider) - - render() - - await waitFor(() => { - expect(mockCreateBuilder).not.toHaveBeenCalled() - }) - }) - - it('should handle empty credentials schema', () => { - const detailWithEmptySchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithEmptySchema) - - render() - - expect(screen.queryByTestId('form-field-api_key')).not.toBeInTheDocument() - }) - - it('should handle undefined trigger in declaration', () => { - const detailWithEmptyDeclaration = createMockPluginDetail({ - declaration: { - trigger: undefined, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithEmptyDeclaration) - - render() - - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - }) - - describe('CREDENTIAL_TYPE_MAP', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUsePluginStore.mockReturnValue(mockPluginDetail) - mockCreateBuilder.mockResolvedValue({ - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - - it('should use correct credential type for APIKEY', async () => { - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalledWith( - expect.objectContaining({ - credential_type: 'api-key', - }), - ) - }) - }) - - it('should use correct credential type for OAUTH', async () => { - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalledWith( - expect.objectContaining({ - credential_type: 'oauth2', - }), - ) - }) - }) - - it('should use correct credential type for MANUAL', async () => { - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalledWith( - expect.objectContaining({ - credential_type: 'unauthorized', - }), - ) - }) - }) - }) - - describe('MODAL_TITLE_KEY_MAP', () => { - it('should use correct title key for APIKEY', () => { - render() - expect( - screen.getByRole('heading', { name: 'pluginTrigger.modal.apiKey.title' }), - ).toBeInTheDocument() - }) - - it('should use correct title key for OAUTH', () => { - render() - expect( - screen.getByRole('heading', { name: 'pluginTrigger.modal.oauth.title' }), - ).toBeInTheDocument() - }) - - it('should use correct title key for MANUAL', () => { - render() - expect( - screen.getByRole('heading', { name: 'pluginTrigger.modal.manual.title' }), - ).toBeInTheDocument() - }) - }) - - describe('Verify Flow', () => { - it('should call verifyCredentials and move to Configuration step on success', async () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - const existingBuilder = createMockSubscriptionBuilder() - mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockVerifyCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'test-provider', - subscriptionBuilderId: existingBuilder.id, - }), - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), - ) - }) - - await waitFor(() => { - expect(screen.getByTestId('modal-confirm')).toHaveTextContent( - 'pluginTrigger.modal.common.create', - ) - }) - }) - - it('should show error on verify failure', async () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - const existingBuilder = createMockSubscriptionBuilder() - mockVerifyCredentials.mockImplementation((params, { onError }) => { - onError(new Error('Verification failed')) - }) - - render() - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockVerifyCredentials).toHaveBeenCalled() - }) - }) - }) - - describe('Create Flow', () => { - it('should show error when subscriptionBuilder is not found in Configuration step', async () => { - // Start in Configuration step (Manual method) - render() - - // Before builder is created, click confirm - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Subscription builder not found', - }) - }) - }) - - it('should call buildSubscription on successful create', async () => { - const builder = createMockSubscriptionBuilder() - mockBuildSubscription.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - // Verify form is rendered and confirm button is clickable - expect(screen.getByTestId('modal-confirm')).toBeInTheDocument() - }) - - it('should show error toast when buildSubscription fails', async () => { - const builder = createMockSubscriptionBuilder() - mockBuildSubscription.mockImplementation((params, { onError }) => { - onError(new Error('Build failed')) - }) - - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - // Verify the modal is still rendered after error - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should call refetch and onClose on successful create', async () => { - const mockOnClose = vi.fn() - const builder = createMockSubscriptionBuilder() - mockBuildSubscription.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render( - , - ) - - // Verify component renders with builder - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - }) - - describe('Manual Properties Change', () => { - it('should call updateBuilder when manual properties change', async () => { - const builder = createMockSubscriptionBuilder() - const detailWithManualSchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithManualSchema) - - render( - , - ) - - const input = screen.getByTestId('form-field-webhook_url') - fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) - - // updateBuilder should be called after debounce - await waitFor(() => { - expect(mockUpdateBuilder).toHaveBeenCalled() - }) - }) - - it('should not call updateBuilder when subscriptionBuilder is missing', async () => { - const detailWithManualSchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithManualSchema) - mockCreateBuilder.mockResolvedValue({ subscription_builder: undefined }) - - render() - - const input = screen.getByTestId('form-field-webhook_url') - fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) - - // updateBuilder should not be called - expect(mockUpdateBuilder).not.toHaveBeenCalled() - }) - }) - - describe('UpdateBuilder Error Handling', () => { - it('should show error toast when updateBuilder fails', async () => { - const detailWithManualSchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithManualSchema) - mockUpdateBuilder.mockImplementation((params, { onError }) => { - onError(new Error('Update failed')) - }) - - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalled() - }) - - // Flush pending state updates from createBuilder promise resolution - await act(async () => {}) - - const input = screen.getByTestId('form-field-webhook_url') - fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) - - await waitFor(() => { - expect(mockUpdateBuilder).toHaveBeenCalled() - }) - - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - ) - }) - }) - }) - - describe('Private Address Warning', () => { - it('should show warning when callback URL is private address', async () => { - const { isPrivateOrLocalAddress } = await import('@/utils/urlValidation') - vi.mocked(isPrivateOrLocalAddress).mockReturnValue(true) - - const builder = createMockSubscriptionBuilder({ - endpoint: 'http://localhost:3000/callback', - }) - - render( - , - ) - - // Verify component renders with the private address endpoint - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - - it('should clear warning when callback URL is not private address', async () => { - const { isPrivateOrLocalAddress } = await import('@/utils/urlValidation') - vi.mocked(isPrivateOrLocalAddress).mockReturnValue(false) - - const builder = createMockSubscriptionBuilder({ - endpoint: 'https://example.com/callback', - }) - - render( - , - ) - - // Verify component renders with public address endpoint - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - }) - - describe('Auto Parameters Schema', () => { - it('should render auto parameters form for OAuth method', () => { - const detailWithAutoParams = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [ - { name: 'repo_name', type: 'string', required: true }, - { name: 'branch', type: 'text', required: false }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithAutoParams) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-repo_name')).toBeInTheDocument() - expect(screen.getByTestId('form-field-branch')).toBeInTheDocument() - }) - - it('should not render auto parameters form for Manual method', () => { - const detailWithAutoParams = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'repo_name', type: 'string', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithAutoParams) - - render() - - // For manual method, auto parameters should not be rendered - expect(screen.queryByTestId('form-field-repo_name')).not.toBeInTheDocument() - }) - }) - - describe('Form Type Normalization', () => { - it('should normalize various form types in auto parameters', () => { - const detailWithVariousTypes = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [ - { name: 'text_field', type: 'string' }, - { name: 'secret_field', type: 'password' }, - { name: 'number_field', type: 'number' }, - { name: 'bool_field', type: 'boolean' }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithVariousTypes) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-secret_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-number_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument() - }) - - it('should handle integer type as number', () => { - const detailWithInteger = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'count', type: 'integer' }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithInteger) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-count')).toBeInTheDocument() - }) - }) - - describe('API Key Credentials Change', () => { - it('should clear errors when credentials change', () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - render() - - const input = screen.getByTestId('form-field-api_key') - fireEvent.change(input, { target: { value: 'new-api-key' } }) - - // Verify the input field exists and accepts changes - expect(input).toBeInTheDocument() - }) - }) - - describe('Subscription Form in Configuration Step', () => { - it('should render subscription name and callback URL fields', () => { - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - }) - - describe('Pending States', () => { - it('should show verifying text when isVerifyingCredentials is true', () => { - setMockPendingStates(true, false) - - render() - - expect(screen.getByTestId('modal-confirm')).toHaveTextContent( - 'pluginTrigger.modal.common.verifying', - ) - }) - - it('should show creating text when isBuilding is true', () => { - setMockPendingStates(false, true) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('modal-confirm')).toHaveTextContent( - 'pluginTrigger.modal.common.creating', - ) - }) - - it('should disable confirm button when verifying', () => { - setMockPendingStates(true, false) - - render() - - expect(screen.getByTestId('modal-confirm')).toBeDisabled() - }) - - it('should disable confirm button when building', () => { - setMockPendingStates(false, true) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('modal-confirm')).toBeDisabled() - }) - }) - - describe('Modal Size', () => { - it('should use md size for Manual method', () => { - render() - - expect(screen.getByRole('dialog')).toHaveClass('w-160') - }) - - it('should use sm size for API Key method', () => { - render() - - expect(screen.getByRole('dialog')).not.toHaveClass('w-160') - }) - - it('should use sm size for OAuth method', () => { - render() - - expect(screen.getByRole('dialog')).not.toHaveClass('w-160') - }) - }) - - describe('BottomSlot', () => { - it('should show EncryptedBottom in Verify step', () => { - render() - - expect(screen.getByTestId('encrypted-bottom')).toBeInTheDocument() - }) - - it('should not show EncryptedBottom in Configuration step', () => { - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.queryByTestId('encrypted-bottom')).not.toBeInTheDocument() - }) - }) - - describe('Form Validation Failure', () => { - it('should return early when subscription form validation fails', async () => { - // Subscription form fails validation - setMockFormValidation(false, true, true) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - // buildSubscription should not be called when validation fails - expect(mockBuildSubscription).not.toHaveBeenCalled() - }) - - it('should return early when auto parameters validation fails', async () => { - // Subscription form passes, but auto params form fails - setMockFormValidation(true, false, true) - - const detailWithAutoParams = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'repo_name', type: 'string', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithAutoParams) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - // buildSubscription should not be called when validation fails - expect(mockBuildSubscription).not.toHaveBeenCalled() - }) - - it('should return early when manual properties validation fails', async () => { - // Subscription form passes, but manual properties form fails - setMockFormValidation(true, true, false) - - const detailWithManualSchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithManualSchema) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - // buildSubscription should not be called when validation fails - expect(mockBuildSubscription).not.toHaveBeenCalled() - }) - }) - - describe('Error Message Parsing', () => { - it('should use parsed error message when available for verify error', async () => { - mockParsePluginErrorMessage.mockReturnValue('Custom parsed error') - - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - mockVerifyCredentials.mockImplementation((params, { onError }) => { - onError(new Error('Raw error')) - }) - const builder = createMockSubscriptionBuilder() - - render() - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockVerifyCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'test-provider', - subscriptionBuilderId: builder.id, - }), - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), - ) - }) - - await waitFor(() => { - expect(mockParsePluginErrorMessage).toHaveBeenCalledWith(expect.any(Error)) - }) - }) - - it('should use parsed error message when available for build error', async () => { - mockParsePluginErrorMessage.mockReturnValue('Custom build error') - - const builder = createMockSubscriptionBuilder() - mockBuildSubscription.mockImplementation((params, { onError }) => { - onError(new Error('Raw build error')) - }) - - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockParsePluginErrorMessage).toHaveBeenCalled() - }) - - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Custom build error', - }) - }) - }) - - it('should use fallback error message when parsePluginErrorMessage returns null', async () => { - mockParsePluginErrorMessage.mockReturnValue(null) - - const builder = createMockSubscriptionBuilder() - mockBuildSubscription.mockImplementation((params, { onError }) => { - onError(new Error('Raw error')) - }) - - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'pluginTrigger.subscription.createFailed', - }) - }) - }) - - it('should use parsed error message for update builder error', async () => { - mockParsePluginErrorMessage.mockReturnValue('Custom update error') - - const detailWithManualSchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithManualSchema) - mockUpdateBuilder.mockImplementation((params, { onError }) => { - onError(new Error('Update failed')) - }) - - render() - - // Wait for createBuilder to complete and state to update - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalled() - }) - - // Allow React to process the state update from createBuilder - await act(async () => {}) - - const input = screen.getByTestId('form-field-webhook_url') - fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) - - // Wait for updateBuilder to be called, then check the toast - await waitFor(() => { - expect(mockUpdateBuilder).toHaveBeenCalled() - }) - - await waitFor(() => { - expect(mockUpdateBuilder).toHaveBeenCalled() - }) - - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Custom update error', - }) - }) - }) - }) - - describe('Form getForm null handling', () => { - it('should handle getForm returning null', async () => { - setMockGetFormReturnsNull(true) - - const builder = createMockSubscriptionBuilder({ - endpoint: 'https://example.com/callback', - }) - - render( - , - ) - - // Component should render without errors even when getForm returns null - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - }) - - describe('normalizeFormType with existing FormTypeEnum', () => { - it('should return the same type when already a valid FormTypeEnum', () => { - const detailWithFormTypeEnum = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [ - { name: 'text_input_field', type: 'text-input' }, - { name: 'secret_input_field', type: 'secret-input' }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithFormTypeEnum) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-text_input_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-secret_input_field')).toBeInTheDocument() - }) - - it('should handle unknown type by defaulting to textInput', () => { - const detailWithUnknownType = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'unknown_field', type: 'unknown-type' }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithUnknownType) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-unknown_field')).toBeInTheDocument() - }) - }) - - describe('Verify Success Flow', () => { - it('should show success toast and move to Configuration step on verify success', async () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - const builder = createMockSubscriptionBuilder() - - render() - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'pluginTrigger.modal.apiKey.verify.success', - }) - }) - }) - }) - - describe('Build Success Flow', () => { - it('should call refetch and onClose on successful build', async () => { - const mockOnClose = vi.fn() - const builder = createMockSubscriptionBuilder() - mockBuildSubscription.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalled() - }) - - await waitFor(() => { - expect(mockRefetch).toHaveBeenCalled() - }) - }) - }) - - describe('DynamicSelect Parameters', () => { - it('should handle dynamic-select type parameters', () => { - const detailWithDynamicSelect = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'dynamic_field', type: 'dynamic-select', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithDynamicSelect) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument() - }) - }) - - describe('Boolean Type Parameters', () => { - it('should handle boolean type parameters with special styling', () => { - const detailWithBoolean = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'bool_field', type: 'boolean', required: false }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithBoolean) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument() - }) - }) - - describe('Empty Form Values', () => { - it('should show error when credentials form returns empty values', () => { - setMockFormValuesConfig({ - values: {}, - isCheckValidated: false, - }) - - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - // Provide builder so the guard passes and credentials check is reached - render() - - fireEvent.click(screen.getByTestId('modal-confirm')) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Please fill in all required credentials', - }) - }) - }) - - describe('Auto Parameters with Empty Schema', () => { - it('should not render auto parameters when schema is empty', () => { - const detailWithEmptyParams = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithEmptyParams) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - // Should only have subscription form fields - expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - }) - - describe('Manual Properties with Empty Schema', () => { - it('should not render manual properties form when schema is empty', () => { - const detailWithEmptySchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithEmptySchema) - - render() - - // Should have subscription form but not manual properties - expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() - expect(screen.queryByTestId('form-field-webhook_url')).not.toBeInTheDocument() - }) - }) - - describe('Credentials Schema with Help Text', () => { - it('should transform help to tooltip in credentials schema', () => { - const detailWithHelp = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [ - { name: 'api_key', type: 'secret', required: true, help: 'Enter your API key' }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithHelp) - - render() - - expect(screen.getByTestId('form-field-api_key')).toBeInTheDocument() - }) - }) - - describe('Auto Parameters with Description', () => { - it('should transform description to tooltip in auto parameters', () => { - const detailWithDescription = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [ - { - name: 'repo_name', - type: 'string', - required: true, - description: 'Repository name', - }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithDescription) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-repo_name')).toBeInTheDocument() - }) - }) - - describe('Manual Properties with Description', () => { - it('should transform description to tooltip in manual properties', () => { - const detailWithDescription = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [ - { name: 'webhook_url', type: 'text', required: true, description: 'Webhook URL' }, - ], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithDescription) - - render() - - expect(screen.getByTestId('form-field-webhook_url')).toBeInTheDocument() - }) - }) - - describe('MultiSteps Component', () => { - it('should not render MultiSteps for OAuth method', () => { - render() - - expect(screen.queryByText('pluginTrigger.modal.steps.verify')).not.toBeInTheDocument() - }) - - it('should not render MultiSteps for Manual method', () => { - render() - - expect(screen.queryByText('pluginTrigger.modal.steps.verify')).not.toBeInTheDocument() - }) - }) - - describe('API Key Build with Parameters', () => { - it('should include parameters in build request for API Key method', async () => { - const detailWithParams = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - parameters: [{ name: 'repo', type: 'string', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithParams) - - // First verify credentials - mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockBuildSubscription.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - const builder = createMockSubscriptionBuilder() - render() - - // Click verify - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockVerifyCredentials).toHaveBeenCalled() - }) - - // Now in configuration step, click create - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockBuildSubscription).toHaveBeenCalled() - }) - }) - }) - - describe('OAuth Build Flow', () => { - it('should handle OAuth build flow correctly', async () => { - const detailWithOAuth = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithOAuth) - mockBuildSubscription.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockBuildSubscription).toHaveBeenCalled() - }) - }) - }) - - describe('StatusStep Component Branches', () => { - it('should render active indicator dot when step is active', () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - render() - - // Verify step is shown (active step has different styling) - expect(screen.getByText('pluginTrigger.modal.steps.verify')).toBeInTheDocument() - }) - - it('should not render active indicator for inactive step', () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - render() - - // Configuration step should be inactive - expect(screen.getByText('pluginTrigger.modal.steps.configuration')).toBeInTheDocument() - }) - }) - - describe('refetch Optional Chaining', () => { - it('should call refetch when available on successful build', async () => { - const builder = createMockSubscriptionBuilder() - mockBuildSubscription.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render( - , - ) - - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockRefetch).toHaveBeenCalled() - }) - }) - }) - - describe('Combined Parameter Types', () => { - it('should render parameters with mixed types including dynamic-select and boolean', () => { - const detailWithMixedTypes = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [ - { name: 'dynamic_field', type: 'dynamic-select', required: true }, - { name: 'bool_field', type: 'boolean', required: false }, - { name: 'text_field', type: 'string', required: true }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithMixedTypes) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument() - }) - - it('should render parameters without dynamic-select type', () => { - const detailWithNonDynamic = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [ - { name: 'text_field', type: 'string', required: true }, - { name: 'number_field', type: 'number', required: false }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithNonDynamic) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-number_field')).toBeInTheDocument() - }) - - it('should render parameters without boolean type', () => { - const detailWithNonBoolean = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [ - { name: 'text_field', type: 'string', required: true }, - { name: 'secret_field', type: 'password', required: true }, - ], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithNonBoolean) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-secret_field')).toBeInTheDocument() - }) - }) - - describe('Endpoint Default Value', () => { - it('should handle undefined endpoint in subscription builder', () => { - const builderWithoutEndpoint = createMockSubscriptionBuilder({ - endpoint: undefined, - }) - - render( - , - ) - - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - - it('should handle empty string endpoint in subscription builder', () => { - const builderWithEmptyEndpoint = createMockSubscriptionBuilder({ - endpoint: '', - }) - - render( - , - ) - - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - }) - - describe('Plugin Detail Fallbacks', () => { - it('should handle undefined plugin_id', () => { - const detailWithoutPluginId = createMockPluginDetail({ - plugin_id: '', - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'dynamic_field', type: 'dynamic-select', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithoutPluginId) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument() - }) - - it('should handle undefined name in plugin detail', () => { - const detailWithoutName = createMockPluginDetail({ - name: '', - }) - mockUsePluginStore.mockReturnValue(detailWithoutName) - - render() - - expect(screen.getByTestId('log-viewer')).toBeInTheDocument() - }) - }) - - describe('Log Data Fallback', () => { - it('should render log viewer even with empty logs', () => { - render() - - // LogViewer should render with empty logs array (from mock) - expect(screen.getByTestId('log-viewer')).toBeInTheDocument() - }) - }) - - describe('Disabled State', () => { - it('should show disabled state when verifying', () => { - setMockPendingStates(true, false) - - render() - - expect(screen.getByTestId('modal-confirm')).toBeDisabled() - }) - - it('should show disabled state when building', () => { - setMockPendingStates(false, true) - const builder = createMockSubscriptionBuilder() - - render( - , - ) - - expect(screen.getByTestId('modal-confirm')).toBeDisabled() - }) - }) - - describe('normalizeFormType Additional Branches', () => { - it('should handle "text" type by returning textInput', () => { - const detailWithText = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'text_type_field', type: 'text' }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithText) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-text_type_field')).toBeInTheDocument() - }) - - it('should handle "secret" type by returning secretInput', () => { - const detailWithSecret = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [], - parameters: [{ name: 'secret_type_field', type: 'secret' }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithSecret) - - const builder = createMockSubscriptionBuilder() - render( - , - ) - - expect(screen.getByTestId('form-field-secret_type_field')).toBeInTheDocument() - }) - }) - - describe('HandleManualPropertiesChange Provider Fallback', () => { - it('should not call updateBuilder when provider is empty', async () => { - const detailWithEmptyProvider = createMockPluginDetail({ - provider: '', - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithEmptyProvider) - - render() - - const input = screen.getByTestId('form-field-webhook_url') - fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) - - // updateBuilder should not be called when provider is empty - expect(mockUpdateBuilder).not.toHaveBeenCalled() - }) - }) - - describe('Configuration Step Without Endpoint', () => { - it('should handle builder without endpoint', async () => { - const builderWithoutEndpoint = createMockSubscriptionBuilder({ - endpoint: '', - }) - - render( - , - ) - - // Component should render without errors - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - }) - - describe('ApiKeyStep Flow Additional Coverage', () => { - it('should handle verify when no builder created yet', async () => { - const detailWithCredentials = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithCredentials) - - // Make createBuilder slow - mockCreateBuilder.mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 1000)), - ) - - render() - - // Click verify before builder is created - fireEvent.click(screen.getByTestId('modal-confirm')) - - // Should still attempt to verify - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - }) - - describe('Auto Parameters Not For APIKEY in Configuration', () => { - it('should include parameters for APIKEY in configuration step', async () => { - const detailWithParams = createMockPluginDetail({ - declaration: { - trigger: { - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'secret', required: true }], - parameters: [{ name: 'extra_param', type: 'string', required: true }], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithParams) - - // First verify credentials - mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - const builder = createMockSubscriptionBuilder() - render() - - // Click verify - fireEvent.click(screen.getByTestId('modal-confirm')) - - await waitFor(() => { - expect(mockVerifyCredentials).toHaveBeenCalled() - }) - - // Now in configuration step, should see extra_param - expect(screen.getByTestId('form-field-extra_param')).toBeInTheDocument() - }) - }) - - describe('needCheckValidatedValues Option', () => { - it('should pass needCheckValidatedValues: false for manual properties', async () => { - const detailWithManualSchema = createMockPluginDetail({ - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'text', required: true }], - subscription_constructor: { - credentials_schema: [], - parameters: [], - }, - }, - }, - }) - mockUsePluginStore.mockReturnValue(detailWithManualSchema) - - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalled() - }) - - // Flush pending state updates from createBuilder promise resolution - await act(async () => {}) +it('shows a verification failure and lets the user correct credentials before continuing', async () => { + const user = userEvent.setup() + requests.verify.mockRejectedValueOnce(new Error('Credentials rejected')) + renderWithConsoleQuery( + , + ) + const input = screen.getByRole('textbox', { name: /API key/ }) + await user.type(input, 'wrong') + await user.click(screen.getByRole('button', { name: verifyName })) + expect(await screen.findByText('Credentials rejected')).toBeInTheDocument() + await user.clear(input) + await user.type(input, 'correct') + await user.click(screen.getByRole('button', { name: verifyName })) + await screen.findByRole('textbox', { name: subscriptionName }) + expect(requests.verify).toHaveBeenLastCalledWith( + expect.objectContaining({ body: { credentials: { api_key: 'correct' } } }), + { context: { silent: true } }, + ) +}) - const input = screen.getByTestId('form-field-webhook_url') - fireEvent.change(input, { target: { value: 'test' } }) +it('keeps configuration open after creation fails and permits retry', async () => { + const user = userEvent.setup() + requests.build.mockRejectedValueOnce(new Error('Build failed')) + const onClose = vi.fn() + renderWithConsoleQuery( + , + ) + await user.type(screen.getByRole('textbox', { name: subscriptionName }), 'Repo updates') + await user.type(screen.getByRole('textbox', { name: /Repository/ }), 'dify') + await user.click(screen.getByRole('button', { name: createName })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Build failed')) + expect(onClose).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: createName })) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) +}) - await waitFor(() => { - expect(mockUpdateBuilder).toHaveBeenCalled() - }) - }) - }) +it('debounces manual property edits and validates them before creating the subscription', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderWithConsoleQuery( + , + ) + await user.type(screen.getByRole('textbox', { name: subscriptionName }), 'Manual updates') + await user.click(screen.getByRole('button', { name: createName })) + expect(requests.build).not.toHaveBeenCalled() + await user.type(screen.getByRole('textbox', { name: /Channel/ }), 'release') + expect(requests.update).not.toHaveBeenCalled() + await waitFor(() => + expect(requests.update).toHaveBeenCalledExactlyOnceWith({ + params: { provider: 'provider-a', subscription_builder_id: 'builder-1' }, + body: { properties: { channel: 'release' } }, + }), + ) + await user.click(screen.getByRole('button', { name: createName })) + expect(requests.build).toHaveBeenCalledWith({ + params: { provider: 'provider-a', subscription_builder_id: 'builder-1' }, + body: { name: 'Manual updates' }, + }) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx index 46c7e251aa07e1..1db431661375bc 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx @@ -3,9 +3,10 @@ import type { TriggerProviderApiEntity, TriggerSubscription, } from '@/app/components/workflow/block-selector/types' -import { render, screen, within } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { CreateSubscriptionButton } from '../index' import { CreateButtonType } from '../types' @@ -31,14 +32,26 @@ vi.mock('../../use-subscription-list', () => ({ let mockProviderInfo: TriggerProviderApiEntity | undefined let mockOAuthConfig: TriggerOAuthConfig | undefined const mockRefetchOAuthConfig = vi.fn() -const mockInitiateOAuth = vi.fn() +const { mockInitiateOAuth } = vi.hoisted(() => ({ mockInitiateOAuth: vi.fn() })) -vi.mock('@/service/use-triggers', () => ({ +vi.mock('@/service/use-triggers', async (importOriginal) => ({ + ...(await importOriginal()), useTriggerProviderInfo: () => ({ data: mockProviderInfo }), useTriggerOAuthConfig: () => ({ data: mockOAuthConfig, refetch: mockRefetchOAuthConfig }), - useInitiateTriggerOAuth: () => ({ mutate: mockInitiateOAuth }), })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.triggerProvider.byProvider.subscriptions.oauth.authorize.get': + mockInitiateOAuth, + }), + } +}) + const mockOpenOAuthPopup = vi.fn() vi.mock('@/hooks/use-oauth', () => ({ openOAuthPopup: (...args: unknown[]) => mockOpenOAuthPopup(...args), @@ -175,13 +188,19 @@ describe('CreateSubscriptionButton', () => { const user = userEvent.setup() mockProviderInfo = providerInfo([SupportedCreationMethods.OAUTH]) mockOAuthConfig = oauthConfig(true) - mockInitiateOAuth.mockImplementationOnce( - (_provider: string, { onSuccess }: { onSuccess: (response: unknown) => void }) => - onSuccess({ - authorization_url: 'https://example.com/oauth', - subscription_builder: { id: 'builder-1' }, - }), - ) + mockInitiateOAuth.mockResolvedValueOnce({ + authorization_url: 'https://example.com/oauth', + subscription_builder: { + id: 'builder-1', + provider: 'test-provider', + name: '', + credential_type: 'oauth2', + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + }, + }) mockOpenOAuthPopup.mockImplementationOnce((_url: string, callback: (data: unknown) => void) => callback({ success: true }), ) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx index c60948c5db19bf..7bab49c388c1a0 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/oauth-client.spec.tsx @@ -1,1320 +1,192 @@ +import type { ParametersSchema } from '@/app/components/plugins/types' import type { TriggerOAuthConfig, TriggerSubscriptionBuilder, } from '@/app/components/workflow/block-selector/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { FormTypeEnum } from '@/app/components/base/form/types' import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types' -import { createHttpError } from '@/service/http-error' +import { toast } from '@/app/notifications' +import { openOAuthPopup } from '@/hooks/use-oauth' +import { renderWithConsoleQuery } from '@/test/console/query-data' import { OAuthClientSettingsModal } from '../oauth-client' -type PluginDetail = { - plugin_id: string - provider: string - name: string -} - -function createMockOAuthConfig(overrides: Partial = {}): TriggerOAuthConfig { +const requests = vi.hoisted(() => ({ + configure: vi.fn(), + authorize: vi.fn(), + verify: vi.fn(), + remove: vi.fn(), +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + const prefix = 'workspaces.current.triggerProvider.byProvider' return { - configured: true, - custom_configured: false, - custom_enabled: false, - system_configured: true, - redirect_uri: 'https://example.com/oauth/callback', - params: { - client_id: 'default-client-id', - client_secret: 'default-client-secret', - }, - oauth_client_schema: [ - { - name: 'client_id', - type: 'text-input' as unknown, - required: true, - label: { 'en-US': 'Client ID' } as unknown, - }, - { - name: 'client_secret', - type: 'secret-input' as unknown, - required: true, - label: { 'en-US': 'Client Secret' } as unknown, - }, - ] as TriggerOAuthConfig['oauth_client_schema'], - ...overrides, + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + [`${prefix}.oauth.client.post`]: requests.configure, + [`${prefix}.oauth.client.delete`]: requests.remove, + [`${prefix}.subscriptions.oauth.authorize.get`]: requests.authorize, + [`${prefix}.subscriptions.builder.verifyAndUpdate.bySubscriptionBuilderId.post`]: + requests.verify, + }), } +}) +vi.mock('../../../store', () => ({ usePluginStore: () => ({ provider: 'provider-a' }) })) +vi.mock('@/hooks/use-oauth', () => ({ openOAuthPopup: vi.fn() })) +vi.mock('@/app/notifications', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const field = (name: string, label: string, type: FormTypeEnum): ParametersSchema => ({ + name, + label: { 'en-US': label, en_US: label } as ParametersSchema['label'], + type, + required: true, + multiple: false, + description: {} as ParametersSchema['description'], + auto_generate: null, + template: null, + scope: null, + min: null, + max: null, + precision: null, +}) +const config: TriggerOAuthConfig = { + configured: true, + custom_configured: true, + custom_enabled: true, + system_configured: false, + redirect_uri: 'https://example.com/oauth/callback', + params: { client_id: 'existing-id', client_secret: 'existing-secret' }, + oauth_client_schema: [ + field('client_id', 'Client ID', FormTypeEnum.textInput), + field('client_secret', 'Client Secret', FormTypeEnum.secretInput), + ], } - -function createMockPluginDetail(overrides: Partial = {}): PluginDetail { - return { - plugin_id: 'test-plugin-id', - provider: 'test-provider', - name: 'Test Plugin', - ...overrides, - } +const builder: TriggerSubscriptionBuilder = { + id: 'builder-1', + provider: 'provider-a', + name: '', + credential_type: TriggerCredentialType.Oauth2, + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + workflows_in_use: 0, } - -function createMockSubscriptionBuilder( - overrides: Partial = {}, -): TriggerSubscriptionBuilder { - return { - id: 'builder-123', - name: 'Test Builder', - provider: 'test-provider', - credential_type: TriggerCredentialType.Oauth2, - credentials: {}, - endpoint: 'https://example.com/callback', - parameters: {}, - properties: {}, - workflows_in_use: 0, - ...overrides, - } +const renderModal = (oauthConfig = config) => { + const onOpenChange = vi.fn() + const showOAuthCreateModal = vi.fn() + const view = renderWithConsoleQuery( + , + ) + return { ...view, onOpenChange, showOAuthCreateModal } } - -const mockPluginDetail = createMockPluginDetail() -const mockUsePluginStore = vi.fn(() => mockPluginDetail) -vi.mock('../../../store', () => ({ - usePluginStore: () => mockUsePluginStore(), -})) - -const mockInitiateOAuth = vi.fn() -const mockVerifyBuilder = vi.fn() -const mockConfigureOAuth = vi.fn() -const mockDeleteOAuth = vi.fn() - -vi.mock('@/service/use-triggers', () => ({ - useInitiateTriggerOAuth: () => ({ - mutate: mockInitiateOAuth, - }), - useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ - mutate: mockVerifyBuilder, - }), - useConfigureTriggerOAuth: () => ({ - mutate: mockConfigureOAuth, - }), - useDeleteTriggerOAuth: () => ({ - mutate: mockDeleteOAuth, - }), -})) - -const mockOpenOAuthPopup = vi.fn() -vi.mock('@/hooks/use-oauth', () => ({ - openOAuthPopup: (url: string, callback: (data: unknown) => void) => - mockOpenOAuthPopup(url, callback), -})) - -const mockToastNotify = vi.fn() -vi.mock('@/app/notifications', () => ({ - toast: Object.assign( - (message: string, options?: { type?: string }) => - mockToastNotify({ type: options?.type, message }), - { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - ), -})) - -const mockClipboardWriteText = vi.fn() -Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: mockClipboardWriteText, - }, - configurable: true, - writable: true, +beforeEach(() => { + requests.configure.mockResolvedValue({}) + requests.authorize.mockResolvedValue({ + authorization_url: 'https://example.com/authorize', + subscription_builder: builder, + }) + requests.verify.mockResolvedValue({ verified: true }) + requests.remove.mockResolvedValue({}) +}) +afterEach(() => vi.useRealTimers()) + +it('validates custom client input and sends edited values while preserving unchanged secrets', async () => { + const user = userEvent.setup() + const { onOpenChange } = renderModal() + const id = screen.getByRole('textbox', { name: /Client ID/ }) + expect(id).toHaveValue('existing-id') + await user.clear(id) + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveOnly' })) + expect(requests.configure).not.toHaveBeenCalled() + await user.type(id, 'new-id') + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveOnly' })) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false)) + expect(requests.configure).toHaveBeenCalledExactlyOnceWith({ + params: { provider: 'provider-a' }, + body: { enabled: true, client_params: { client_id: 'new-id', client_secret: '[__HIDDEN__]' } }, + }) + expect(requests.authorize).not.toHaveBeenCalled() }) -let mockFormValues: { values: Record; isCheckValidated: boolean } = { - values: { client_id: 'test-client-id', client_secret: 'test-client-secret' }, - isCheckValidated: true, -} -const setMockFormValues = (values: typeof mockFormValues) => { - mockFormValues = values -} - -vi.mock('@/app/components/base/form/components/base', () => ({ - BaseForm: ({ - formSchemas, - ref, - }: { - formSchemas: Array<{ name: string; default?: string }> - ref?: React.Ref<{ - getFormValues: () => { values: Record; isCheckValidated: boolean } - }> - }) => { - React.useImperativeHandle(ref, () => ({ - getFormValues: () => mockFormValues, - })) - return ( -
- {formSchemas.map((schema) => ( - - ))} -
- ) - }, -})) - -describe('OAuthClientSettingsModal', () => { - const defaultProps = { - open: true, - oauthConfig: createMockOAuthConfig(), - onOpenChange: vi.fn(), - showOAuthCreateModal: vi.fn(), - } - const title = 'pluginTrigger.modal.oauth.title' - const getDialog = () => screen.getByRole('dialog', { name: title }) - const getCloseButton = () => screen.getByRole('button', { name: 'common.operation.close' }) - const getCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' }) - const getSaveOnlyButton = () => screen.getByRole('button', { name: 'plugin.auth.saveOnly' }) - const getConfirmButton = () => - screen.getByRole('button', { - name: /plugin\.auth\.saveAndAuth|pluginTrigger\.modal\.common\.authorizing|pluginTrigger\.modal\.oauth\.authorization\.waitingJump/, - }) - - beforeEach(() => { - vi.clearAllMocks() - mockUsePluginStore.mockReturnValue(mockPluginDetail) - mockClipboardWriteText.mockResolvedValue(undefined) - Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: mockClipboardWriteText, - }, - configurable: true, - writable: true, - }) - setMockFormValues({ - values: { client_id: 'test-client-id', client_secret: 'test-client-secret' }, - isCheckValidated: true, - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render modal with correct title', () => { - render() - - expect(screen.getByRole('heading', { name: title })).toBeInTheDocument() - }) - - it('should render client type selector when system_configured is true', () => { - render() - - expect( - screen.getByText('pluginTrigger.subscription.addType.options.oauth.default'), - ).toBeInTheDocument() - expect( - screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom'), - ).toBeInTheDocument() - }) - - it('should not render client type selector when system_configured is false', () => { - const configWithoutSystemConfigured = createMockOAuthConfig({ - system_configured: false, - }) - - render( - , - ) - - expect( - screen.queryByText('pluginTrigger.subscription.addType.options.oauth.default'), - ).not.toBeInTheDocument() - }) - - it('should render redirect URI info when custom client type is selected', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - }) - - render() - - expect(screen.getByText('pluginTrigger.modal.oauthRedirectInfo')).toBeInTheDocument() - expect(screen.getByText('https://example.com/oauth/callback')).toBeInTheDocument() - }) - - it('should render client form when custom type is selected', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - }) - - render() - - expect(screen.getByTestId('base-form')).toBeInTheDocument() - }) - - it('should show remove button when custom_enabled and params exist', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - render() - - expect(screen.getByText('common.operation.remove')).toBeInTheDocument() - }) - }) - - describe('Client Type Selection', () => { - it('should default to Default client type when system_configured is true', () => { - render() - - const defaultCard = screen - .getByText('pluginTrigger.subscription.addType.options.oauth.default') - .closest('div') - expect(defaultCard).toHaveClass('border-[1.5px]') - }) - - it('should switch to Custom client type when Custom card is clicked', () => { - render() - - const customCard = screen - .getByText('pluginTrigger.subscription.addType.options.oauth.custom') - .closest('div') - fireEvent.click(customCard!) - - expect(customCard).toHaveClass('border-[1.5px]') - }) - - it('should switch back to Default client type when Default card is clicked', () => { - render() - - const customCard = screen - .getByText('pluginTrigger.subscription.addType.options.oauth.custom') - .closest('div') - fireEvent.click(customCard!) - - const defaultCard = screen - .getByText('pluginTrigger.subscription.addType.options.oauth.default') - .closest('div') - fireEvent.click(defaultCard!) - - expect(defaultCard).toHaveClass('border-[1.5px]') - }) - }) - - describe('Copy Redirect URI', () => { - it('should copy redirect URI when copy button is clicked', async () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - }) - - render() - - const copyButton = screen.getByText('common.operation.copy') - fireEvent.click(copyButton) - - await waitFor(() => { - expect(mockClipboardWriteText).toHaveBeenCalledWith('https://example.com/oauth/callback') - }) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'common.actionMsg.copySuccessfully', - }) - }) - }) - - describe('OAuth Authorization Flow', () => { - it('should initiate OAuth when confirm button is clicked', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - fireEvent.click(getConfirmButton()) - - expect(mockConfigureOAuth).toHaveBeenCalled() - }) - - it('should open OAuth popup after successful configuration', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - - render() - - fireEvent.click(getConfirmButton()) - - expect(mockOpenOAuthPopup).toHaveBeenCalledWith( - 'https://oauth.example.com/authorize', - expect.any(Function), - ) - }) - - it('should show success toast and close modal when OAuth callback succeeds', () => { - const mockOnOpenChange = vi.fn() - const mockShowOAuthCreateModal = vi.fn() - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - const builder = createMockSubscriptionBuilder() - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: builder, - }) - }) - mockOpenOAuthPopup.mockImplementation((url, callback) => { - callback({ success: true }) - }) - - render( - , - ) - - fireEvent.click(getConfirmButton()) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'pluginTrigger.modal.oauth.authorization.authSuccess', - }) - expect(mockOnOpenChange).toHaveBeenCalledWith(false) - }) - - it('should show error toast when OAuth initiation fails', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onError }) => { - onError(new Error('OAuth failed')) - }) - - render() - - fireEvent.click(getConfirmButton()) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'pluginTrigger.modal.oauth.authorization.authFailed', - }) - }) - }) - - describe('Save Only Flow', () => { - it('should save configuration without authorization when cancel button is clicked', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - fireEvent.click(getSaveOnlyButton()) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'test-provider', - enabled: false, - }), - expect.any(Object), - ) - }) - - it('should show success toast when save only succeeds', () => { - const mockOnOpenChange = vi.fn() - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - fireEvent.click(getSaveOnlyButton()) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'pluginTrigger.modal.oauth.save.success', - }) - expect(mockOnOpenChange).toHaveBeenCalledWith(false) - }) - }) - - describe('Remove OAuth Configuration', () => { - it('should call deleteOAuth when remove button is clicked', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - render() - - const removeButton = screen.getByText('common.operation.remove') - fireEvent.click(removeButton) - - expect(mockDeleteOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) - }) - - it('should show success toast when remove succeeds', () => { - const mockOnOpenChange = vi.fn() - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess() - }) - - render( - , - ) - - const removeButton = screen.getByText('common.operation.remove') - fireEvent.click(removeButton) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'pluginTrigger.modal.oauth.remove.success', - }) - expect(mockOnOpenChange).toHaveBeenCalledWith(false) - }) - - it('should show error toast when remove fails', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - mockDeleteOAuth.mockImplementation((provider, { onError }) => { - onError(new Error('Delete failed')) - }) - - render() - - const removeButton = screen.getByText('common.operation.remove') - fireEvent.click(removeButton) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Delete failed', - }) - }) - }) - - describe('Modal Actions', () => { - it('should call onOpenChange when close button is clicked', async () => { - const user = userEvent.setup() - const mockOnOpenChange = vi.fn() - render() - - await user.click(getCloseButton()) - - expect(mockOnOpenChange.mock.calls[0]?.[0]).toBe(false) - }) - - it('should call onOpenChange when cancel button is clicked', () => { - const mockOnOpenChange = vi.fn() - render() - - fireEvent.click(getCancelButton()) - - expect(mockOnOpenChange).toHaveBeenCalledWith(false) - }) - }) - - describe('Button Text States', () => { - it('should show default button text initially', () => { - render() - - expect(getConfirmButton()).toHaveTextContent('plugin.auth.saveAndAuth') - }) - - it('should show save only button text', () => { - render() - - expect(getSaveOnlyButton()).toHaveTextContent('plugin.auth.saveOnly') - }) - }) - - describe('OAuth Client Schema', () => { - it('should populate form with existing params values', () => { - const configWithParams = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { - client_id: 'existing-client-id', - client_secret: 'existing-client-secret', - }, - }) - - render() - - const clientIdInput = screen.getByTestId('form-field-client_id') as HTMLInputElement - const clientSecretInput = screen.getByTestId('form-field-client_secret') as HTMLInputElement - - expect(clientIdInput.defaultValue).toBe('existing-client-id') - expect(clientSecretInput.defaultValue).toBe('existing-client-secret') - }) - - it('should handle empty oauth_client_schema', () => { - const configWithEmptySchema = createMockOAuthConfig({ - system_configured: false, - oauth_client_schema: [], - }) - - render() - - expect(screen.queryByTestId('base-form')).not.toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle undefined oauthConfig', () => { - render() - - expect(getDialog()).toBeInTheDocument() - }) - - it('should handle missing provider', () => { - const detailWithoutProvider = createMockPluginDetail({ provider: '' }) - mockUsePluginStore.mockReturnValue(detailWithoutProvider) - - render() - - expect(getDialog()).toBeInTheDocument() - }) - }) - - describe('Authorization Status Polling', () => { - it('should initiate polling setup after OAuth starts', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - - render() - - fireEvent.click(getConfirmButton()) - - // Verify OAuth flow was initiated - expect(mockInitiateOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) - }) - - it('should continue polling when verifyBuilder returns an error', async () => { - vi.useFakeTimers() - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockVerifyBuilder.mockImplementation((params, { onError }) => { - onError(new Error('Verify failed')) - }) - - render() - - fireEvent.click(getConfirmButton()) - - vi.advanceTimersByTime(3000) - expect(mockVerifyBuilder).toHaveBeenCalled() - - // Should still be in pending state (polling continues) - expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.authorizing') - - vi.useRealTimers() - }) - }) - - describe('getErrorMessage helper', () => { - it('should extract error message from Error object', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - mockDeleteOAuth.mockImplementation((provider, { onError }) => { - onError(new Error('Custom error message')) - }) - - render() - - fireEvent.click(screen.getByText('common.operation.remove')) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Custom error message', - }) - }) - - it('should extract the backend message from an API error', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - mockDeleteOAuth.mockImplementation((provider, { onError }) => { - onError( - createHttpError(new Response(null, { status: 503 }), { message: 'Object error message' }), - ) - }) - - render() - - fireEvent.click(screen.getByText('common.operation.remove')) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Object error message', - }) - }) - - it('should use fallback message when error has no message', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - mockDeleteOAuth.mockImplementation((provider, { onError }) => { - onError({}) - }) - - render() - - fireEvent.click(screen.getByText('common.operation.remove')) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'pluginTrigger.modal.oauth.remove.failed', - }) - }) - - it('should use fallback when error.message is not a string', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - mockDeleteOAuth.mockImplementation((provider, { onError }) => { - onError({ message: 123 }) - }) - - render() - - fireEvent.click(screen.getByText('common.operation.remove')) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'pluginTrigger.modal.oauth.remove.failed', - }) - }) - - it('should use fallback when error.message is empty string', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - mockDeleteOAuth.mockImplementation((provider, { onError }) => { - onError({ message: '' }) - }) - - render() - - fireEvent.click(screen.getByText('common.operation.remove')) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'pluginTrigger.modal.oauth.remove.failed', - }) - }) - }) - - describe('OAuth callback edge cases', () => { - it('should not show success toast when OAuth callback returns falsy data', () => { - const mockOnOpenChange = vi.fn() - const mockShowOAuthCreateModal = vi.fn() - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockOpenOAuthPopup.mockImplementation((url, callback) => { - callback(null) - }) - - render( - , - ) - - fireEvent.click(getConfirmButton()) - - // Should not show success toast or call callbacks - expect(mockToastNotify).not.toHaveBeenCalledWith( - expect.objectContaining({ message: 'pluginTrigger.modal.oauth.authorization.authSuccess' }), - ) - expect(mockShowOAuthCreateModal).not.toHaveBeenCalled() - }) - }) - - describe('Custom Client Type Save Flow', () => { - it('should send enabled: true when custom client type is selected', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - // Switch to custom - const customCard = screen - .getByText('pluginTrigger.subscription.addType.options.oauth.custom') - .closest('div') - fireEvent.click(customCard!) - - fireEvent.click(getSaveOnlyButton()) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: true, - }), - expect.any(Object), - ) - }) - - it('should send enabled: false when default client type is selected', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - // Default is already selected - fireEvent.click(getSaveOnlyButton()) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: false, - }), - expect.any(Object), - ) - }) - }) - - describe('OAuth Client Schema Default Values', () => { - it('should set default values from params to schema', () => { - const configWithParams = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { - client_id: 'my-client-id', - client_secret: 'my-client-secret', - }, - }) - - render() - - const clientIdInput = screen.getByTestId('form-field-client_id') as HTMLInputElement - const clientSecretInput = screen.getByTestId('form-field-client_secret') as HTMLInputElement - - expect(clientIdInput.defaultValue).toBe('my-client-id') - expect(clientSecretInput.defaultValue).toBe('my-client-secret') - }) - - it('should return empty array when oauth_client_schema is empty', () => { - const configWithEmptySchema = createMockOAuthConfig({ - system_configured: false, - oauth_client_schema: [], - }) - - render() - - expect(screen.queryByTestId('base-form')).not.toBeInTheDocument() - }) - - it('should skip setting default when schema name is not in params', () => { - const configWithPartialParams = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { - client_id: 'my-client-id', - client_secret: '', // empty value - will not be set as default - }, - oauth_client_schema: [ - { - name: 'client_id', - type: 'text-input' as unknown, - required: true, - label: { 'en-US': 'Client ID' } as unknown, - }, - { - name: 'client_secret', - type: 'secret-input' as unknown, - required: true, - label: { 'en-US': 'Client Secret' } as unknown, - }, - { - name: 'extra_param', - type: 'text-input' as unknown, - required: false, - label: { 'en-US': 'Extra Param' } as unknown, - }, - ] as TriggerOAuthConfig['oauth_client_schema'], - }) - - render() - - const clientIdInput = screen.getByTestId('form-field-client_id') as HTMLInputElement - expect(clientIdInput.defaultValue).toBe('my-client-id') - - // client_secret should have empty default since value is empty - const clientSecretInput = screen.getByTestId('form-field-client_secret') as HTMLInputElement - expect(clientSecretInput.defaultValue).toBe('') - }) - }) - - describe('Confirm Button Text States', () => { - it('should show saveAndAuth text by default', () => { - render() - - expect(getConfirmButton()).toHaveTextContent('plugin.auth.saveAndAuth') - }) - - it('should show authorizing text when authorization is pending', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation(() => { - // Don't call callback - stays pending - }) - - render() - - fireEvent.click(getConfirmButton()) - - expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.authorizing') - }) - }) - - describe('Authorization Failed Status', () => { - it('should set authorization status to Failed when OAuth initiation fails', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onError }) => { - onError(new Error('OAuth failed')) - }) - - render() - - fireEvent.click(getConfirmButton()) - - // After failure, button text should return to default - expect(getConfirmButton()).toHaveTextContent('plugin.auth.saveAndAuth') - }) - }) - - describe('Redirect URI Display', () => { - it('should not show redirect URI info when redirect_uri is empty', () => { - const configWithEmptyRedirectUri = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - redirect_uri: '', - }) - - render( - , - ) - - expect(screen.queryByText('pluginTrigger.modal.oauthRedirectInfo')).not.toBeInTheDocument() - }) - - it('should show redirect URI info when custom type and redirect_uri exists', () => { - const configWithRedirectUri = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - redirect_uri: 'https://my-app.com/oauth/callback', - }) - - render() - - expect(screen.getByText('pluginTrigger.modal.oauthRedirectInfo')).toBeInTheDocument() - expect(screen.getByText('https://my-app.com/oauth/callback')).toBeInTheDocument() - }) - }) - - describe('Remove Button Visibility', () => { - it('should not show remove button when custom_enabled is false', () => { - const configWithCustomDisabled = createMockOAuthConfig({ - system_configured: false, - custom_enabled: false, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - render() - - expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() - }) - - it('should not show remove button when default client type is selected', () => { - const configWithCustomEnabled = createMockOAuthConfig({ - system_configured: true, - custom_enabled: true, - params: { client_id: 'test-id', client_secret: 'test-secret' }, - }) - - render() - - // Default is selected by default when system_configured is true - expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() - }) - }) - - describe('OAuth Client Title', () => { - it('should render client type title', () => { - render() - - expect( - screen.getByText('pluginTrigger.subscription.addType.options.oauth.clientTitle'), - ).toBeInTheDocument() - }) - }) - - describe('Form Validation on Custom Save', () => { - it('should not call configureOAuth when form validation fails', () => { - setMockFormValues({ - values: { client_id: '', client_secret: '' }, - isCheckValidated: false, - }) - - render() - - // Switch to custom type - const customCard = screen - .getByText('pluginTrigger.subscription.addType.options.oauth.custom') - .closest('div')! - fireEvent.click(customCard) - - fireEvent.click(getSaveOnlyButton()) - - // Should not call configureOAuth because form validation failed - expect(mockConfigureOAuth).not.toHaveBeenCalled() - }) - }) - - describe('Client Params Hidden Value Transform', () => { - it('should transform client_id to hidden when unchanged', () => { - setMockFormValues({ - values: { client_id: 'default-client-id', client_secret: 'new-secret' }, - isCheckValidated: true, - }) - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - // Switch to custom type - fireEvent.click( - screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, - ) - - fireEvent.click(getSaveOnlyButton()) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - client_params: expect.objectContaining({ - client_id: '[__HIDDEN__]', - client_secret: 'new-secret', - }), - }), - expect.any(Object), - ) - }) - - it('should transform client_secret to hidden when unchanged', () => { - setMockFormValues({ - values: { client_id: 'new-id', client_secret: 'default-client-secret' }, - isCheckValidated: true, - }) - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - // Switch to custom type - fireEvent.click( - screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, - ) - - fireEvent.click(getSaveOnlyButton()) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - client_params: expect.objectContaining({ - client_id: 'new-id', - client_secret: '[__HIDDEN__]', - }), - }), - expect.any(Object), - ) - }) - - it('should transform both client_id and client_secret to hidden when both unchanged', () => { - setMockFormValues({ - values: { client_id: 'default-client-id', client_secret: 'default-client-secret' }, - isCheckValidated: true, - }) - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - // Switch to custom type - fireEvent.click( - screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, - ) - - fireEvent.click(getSaveOnlyButton()) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - client_params: expect.objectContaining({ - client_id: '[__HIDDEN__]', - client_secret: '[__HIDDEN__]', - }), - }), - expect.any(Object), - ) - }) - - it('should send new values when both changed', () => { - setMockFormValues({ - values: { client_id: 'new-client-id', client_secret: 'new-client-secret' }, - isCheckValidated: true, - }) - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - - render() - - // Switch to custom type - fireEvent.click( - screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!, - ) - - fireEvent.click(getSaveOnlyButton()) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - client_params: expect.objectContaining({ - client_id: 'new-client-id', - client_secret: 'new-client-secret', - }), - }), - expect.any(Object), - ) - }) - }) - - describe('Polling Verification Success', () => { - it('should call verifyBuilder and update status on success', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { - onSuccess({ verified: true }) - }) - - render() - - fireEvent.click(getConfirmButton()) - - // Advance timer to trigger polling - await vi.advanceTimersByTimeAsync(3000) - - expect(mockVerifyBuilder).toHaveBeenCalled() - - // Button text should show waitingJump after verified - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent( - 'pluginTrigger.modal.oauth.authorization.waitingJump', - ) - }) - - vi.useRealTimers() - }) - - it('should continue polling when not verified', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => { - onSuccess() - }) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { - onSuccess({ verified: false }) - }) - - render() +it('uses the system client when selected and opens the authorization popup after saving', async () => { + const user = userEvent.setup() + const { onOpenChange, showOAuthCreateModal } = renderModal({ ...config, system_configured: true }) + expect(screen.queryByRole('textbox', { name: /Client ID/ })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) + await waitFor(() => + expect(openOAuthPopup).toHaveBeenCalledWith( + 'https://example.com/authorize', + expect.any(Function), + ), + ) + expect(requests.configure).toHaveBeenCalledWith({ + params: { provider: 'provider-a' }, + body: { enabled: false }, + }) + // This callback is the external OAuth window/message boundary, not a mutation callback. + act(() => vi.mocked(openOAuthPopup).mock.calls[0]![1]({ type: 'oauth_callback', success: true })) + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(showOAuthCreateModal).toHaveBeenCalledWith(builder) +}) - fireEvent.click(getConfirmButton()) +it('shows authorization failure and allows retry without closing the settings', async () => { + const user = userEvent.setup() + requests.authorize.mockRejectedValueOnce(new Error('Authorization failed')) + const { onOpenChange } = renderModal() + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith('pluginTrigger.modal.oauth.authorization.authFailed'), + ) + expect(onOpenChange).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) + await waitFor(() => expect(openOAuthPopup).toHaveBeenCalledOnce()) +}) - // First poll - await vi.advanceTimersByTimeAsync(3000) - expect(mockVerifyBuilder).toHaveBeenCalledTimes(1) +it('reports removal errors and closes only when the retry succeeds', async () => { + const user = userEvent.setup() + requests.remove.mockRejectedValueOnce(new Error('Removal denied')) + const { onOpenChange } = renderModal() + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Removal denied')) + expect(onOpenChange).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false)) + expect(requests.remove).toHaveBeenLastCalledWith({ params: { provider: 'provider-a' } }) +}) - // Second poll +it('continues authorization polling after errors and stops when verification succeeds', async () => { + const user = userEvent.setup() + // Only the polling interval is virtual; React Query and DOM scheduling stay real. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + requests.verify + .mockRejectedValueOnce(new Error('Not ready')) + .mockResolvedValueOnce({ verified: false }) + .mockResolvedValueOnce({ verified: true }) + const { unmount } = renderModal() + await user.click(screen.getByRole('button', { name: 'plugin.auth.saveAndAuth' })) + await waitFor(() => expect(openOAuthPopup).toHaveBeenCalledOnce()) + for (let i = 1; i <= 3; i++) { + await act(async () => { await vi.advanceTimersByTimeAsync(3000) - expect(mockVerifyBuilder).toHaveBeenCalledTimes(2) - - // Should still be in authorizing state - expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.authorizing') - - vi.useRealTimers() - }) - }) - - describe('OAuth Client Schema Params Fallback', () => { - it('should handle schema when params is truthy but schema name not in params', () => { - const configWithSchemaNotInParams = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: { - client_id: 'test-id', - client_secret: 'test-secret', - }, - oauth_client_schema: [ - { - name: 'client_id', - type: 'text-input' as unknown, - required: true, - label: { 'en-US': 'Client ID' } as unknown, - }, - { - name: 'client_secret', - type: 'secret-input' as unknown, - required: true, - label: { 'en-US': 'Client Secret' } as unknown, - }, - { - name: 'extra_field', - type: 'text-input' as unknown, - required: false, - label: { 'en-US': 'Extra' } as unknown, - }, - ] as TriggerOAuthConfig['oauth_client_schema'], - }) - - render( - , - ) - - // extra_field should be rendered but without default value - const extraInput = screen.getByTestId('form-field-extra_field') as HTMLInputElement - expect(extraInput.defaultValue).toBe('') }) - - it('should handle oauth_client_schema with undefined params', () => { - const configWithUndefinedParams = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: undefined as unknown as TriggerOAuthConfig['params'], - oauth_client_schema: [ - { - name: 'client_id', - type: 'text-input' as unknown, - required: true, - label: { 'en-US': 'Client ID' } as unknown, - }, - ] as TriggerOAuthConfig['oauth_client_schema'], - }) - - render() - - // Form should not render because params is undefined (schema condition fails) - expect(screen.queryByTestId('base-form')).not.toBeInTheDocument() - }) - - it('should handle oauth_client_schema with null params', () => { - const configWithNullParams = createMockOAuthConfig({ - system_configured: false, - custom_enabled: true, - params: null as unknown as TriggerOAuthConfig['params'], - oauth_client_schema: [ - { - name: 'client_id', - type: 'text-input' as unknown, - required: true, - label: { 'en-US': 'Client ID' } as unknown, - }, - ] as TriggerOAuthConfig['oauth_client_schema'], - }) - - render() - - // Form should not render because params is null - expect(screen.queryByTestId('base-form')).not.toBeInTheDocument() - }) - }) + await waitFor(() => expect(requests.verify).toHaveBeenCalledTimes(i)) + } + expect( + await screen.findByRole('button', { + name: 'pluginTrigger.modal.oauth.authorization.waitingJump', + }), + ).toBeInTheDocument() + await act(async () => { + await vi.advanceTimersByTimeAsync(6000) + }) + expect(requests.verify).toHaveBeenCalledTimes(3) + unmount() }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts index 2335322e34d4b5..7787c47fb0b2d3 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts @@ -21,9 +21,8 @@ type BuilderResponse = { subscription_builder: TriggerSubscriptionBuilder } -const { mockToastError, mockIsPrivateOrLocalAddress } = vi.hoisted(() => ({ +const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), - mockIsPrivateOrLocalAddress: vi.fn(), })) vi.mock('@/app/notifications', () => ({ @@ -32,14 +31,9 @@ vi.mock('@/app/notifications', () => ({ }, })) -vi.mock('@/utils/urlValidation', () => ({ - isPrivateOrLocalAddress: (value: string) => mockIsPrivateOrLocalAddress(value), -})) - describe('use-common-modal-state helpers', () => { beforeEach(() => { vi.clearAllMocks() - mockIsPrivateOrLocalAddress.mockReturnValue(false) }) it('returns default form values when the form ref is empty', () => { @@ -190,7 +184,6 @@ describe('use-common-modal-state helpers', () => { }) it('syncs callback endpoint and warnings into the subscription form', async () => { - mockIsPrivateOrLocalAddress.mockReturnValue(true) const setFieldValue = vi.fn() const setFields = vi.fn() const subscriptionFormRef = { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts deleted file mode 100644 index 4ca4b14435f369..00000000000000 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { FormRefObject } from '@/app/components/base/form/types' -import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' -import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { SupportedCreationMethods } from '@/app/components/plugins/types' -import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types' -import { ApiKeyStep, useCommonModalState } from '../use-common-modal-state' - -type MockPluginDetail = { - plugin_id: string - provider: string - name: string - declaration: { - trigger: { - subscription_schema: Array<{ name: string; type: string; description?: string }> - subscription_constructor: { - credentials_schema: Array<{ name: string; type: string; help?: string }> - parameters: Array<{ name: string; type: string }> - } - } - } -} - -const createMockBuilder = ( - overrides: Partial = {}, -): TriggerSubscriptionBuilder => ({ - id: 'builder-1', - name: 'builder', - provider: 'provider-a', - credential_type: TriggerCredentialType.ApiKey, - credentials: {}, - endpoint: 'https://example.com/callback', - parameters: {}, - properties: {}, - workflows_in_use: 0, - ...overrides, -}) - -const mockDetail: MockPluginDetail = { - plugin_id: 'plugin-id', - provider: 'provider-a', - name: 'Plugin A', - declaration: { - trigger: { - subscription_schema: [{ name: 'webhook_url', type: 'string', description: 'Webhook URL' }], - subscription_constructor: { - credentials_schema: [{ name: 'api_key', type: 'string', help: 'API key help' }], - parameters: [{ name: 'repo_name', type: 'string' }], - }, - }, - }, -} - -const mockUsePluginStore = vi.fn(() => mockDetail) -vi.mock('../../../../store', () => ({ - usePluginStore: () => mockUsePluginStore(), -})) - -const mockRefetch = vi.fn() -vi.mock('../../../use-subscription-list', () => ({ - useSubscriptionList: () => ({ refetch: mockRefetch }), -})) - -const mockVerifyCredentials = vi.fn() -const mockCreateBuilder = vi.fn() -const mockBuildSubscription = vi.fn() -const mockUpdateBuilder = vi.fn() -let mockIsVerifyingCredentials = false -let mockIsBuilding = false - -vi.mock('@/service/use-triggers', () => ({ - useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ - mutate: mockVerifyCredentials, - get isPending() { - return mockIsVerifyingCredentials - }, - }), - useCreateTriggerSubscriptionBuilder: () => ({ - mutateAsync: mockCreateBuilder, - }), - useBuildTriggerSubscription: () => ({ - mutate: mockBuildSubscription, - get isPending() { - return mockIsBuilding - }, - }), - useUpdateTriggerSubscriptionBuilder: () => ({ - mutate: mockUpdateBuilder, - }), - useTriggerSubscriptionBuilderLogs: () => ({ - data: { logs: [] }, - }), -})) - -const mockToastNotify = vi.fn() -vi.mock('@/app/notifications', () => ({ - toast: { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - }, -})) - -const mockParsePluginErrorMessage = vi.fn().mockReturnValue(null) -vi.mock('@/utils/error-parser', () => ({ - parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args), -})) - -vi.mock('@/utils/urlValidation', () => ({ - isPrivateOrLocalAddress: vi.fn().mockReturnValue(false), -})) - -const createFormRef = ({ - values = {}, - isCheckValidated = true, -}: { - values?: Record - isCheckValidated?: boolean -} = {}): FormRefObject => - ({ - getFormValues: vi.fn().mockReturnValue({ values, isCheckValidated }), - setFields: vi.fn(), - getForm: vi.fn().mockReturnValue({ - setFieldValue: vi.fn(), - }), - }) as unknown as FormRefObject - -describe('useCommonModalState', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsVerifyingCredentials = false - mockIsBuilding = false - mockCreateBuilder.mockResolvedValue({ - subscription_builder: createMockBuilder(), - }) - }) - - it('should initialize api key builders and expose verify step state', async () => { - const { result } = renderHook(() => - useCommonModalState({ - createType: SupportedCreationMethods.APIKEY, - onClose: vi.fn(), - }), - ) - - await waitFor(() => { - expect(result.current.subscriptionBuilder?.id).toBe('builder-1') - }) - - expect(mockCreateBuilder).toHaveBeenCalledWith({ - provider: 'provider-a', - credential_type: TriggerCredentialType.ApiKey, - }) - expect(result.current.currentStep).toBe(ApiKeyStep.Verify) - expect(result.current.apiKeyCredentialsSchema[0]).toMatchObject({ - name: 'api_key', - tooltip: 'API key help', - }) - }) - - it('should verify credentials and advance to configuration step', async () => { - mockVerifyCredentials.mockImplementation((_payload, options) => { - options?.onSuccess?.() - }) - - const builder = createMockBuilder() - const { result } = renderHook(() => - useCommonModalState({ - createType: SupportedCreationMethods.APIKEY, - builder, - onClose: vi.fn(), - }), - ) - - const credentialsFormRef = result.current.formRefs.apiKeyCredentialsFormRef as { - current: FormRefObject | null - } - credentialsFormRef.current = createFormRef({ - values: { api_key: 'secret' }, - }) - - act(() => { - result.current.handleVerify() - }) - - expect(mockVerifyCredentials).toHaveBeenCalledWith( - { - provider: 'provider-a', - subscriptionBuilderId: builder.id, - credentials: { api_key: 'secret' }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), - ) - expect(result.current.currentStep).toBe(ApiKeyStep.Configuration) - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'success', - }), - ) - }) - - it('should build subscriptions with validated automatic parameters', () => { - const onClose = vi.fn() - const builder = createMockBuilder() - const { result } = renderHook(() => - useCommonModalState({ - createType: SupportedCreationMethods.APIKEY, - builder, - onClose, - }), - ) - - const subscriptionFormRef = result.current.formRefs.subscriptionFormRef as { - current: FormRefObject | null - } - const autoParamsFormRef = result.current.formRefs.autoCommonParametersFormRef as { - current: FormRefObject | null - } - - subscriptionFormRef.current = createFormRef({ - values: { subscription_name: 'Subscription A' }, - }) - autoParamsFormRef.current = createFormRef({ - values: { repo_name: 'repo-a' }, - }) - - act(() => { - result.current.handleCreate() - }) - - expect(mockBuildSubscription).toHaveBeenCalledWith( - { - provider: 'provider-a', - subscriptionBuilderId: builder.id, - name: 'Subscription A', - parameters: { repo_name: 'repo-a' }, - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), - ) - }) - - it('should debounce manual property updates', async () => { - vi.useFakeTimers() - - const builder = createMockBuilder({ - credential_type: TriggerCredentialType.Unauthorized, - }) - const { result } = renderHook(() => - useCommonModalState({ - createType: SupportedCreationMethods.MANUAL, - builder, - onClose: vi.fn(), - }), - ) - - const manualFormRef = result.current.formRefs.manualPropertiesFormRef as { - current: FormRefObject | null - } - manualFormRef.current = createFormRef({ - values: { webhook_url: 'https://hook.example.com' }, - isCheckValidated: true, - }) - - act(() => { - result.current.handleManualPropertiesChange() - vi.advanceTimersByTime(500) - }) - - expect(mockUpdateBuilder).toHaveBeenCalledWith( - { - provider: 'provider-a', - subscriptionBuilderId: builder.id, - properties: { webhook_url: 'https://hook.example.com' }, - }, - expect.objectContaining({ - onError: expect.any(Function), - }), - ) - - vi.useRealTimers() - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts deleted file mode 100644 index b97fa2690d4355..00000000000000 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-oauth-client-state.spec.ts +++ /dev/null @@ -1,741 +0,0 @@ -import type { - TriggerOAuthConfig, - TriggerSubscriptionBuilder, -} from '@/app/components/workflow/block-selector/types' -import { act, renderHook, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types' -import { createHttpError } from '@/service/http-error' -import { - AuthorizationStatusEnum, - ClientTypeEnum, - useOAuthClientState, -} from '../use-oauth-client-state' - -// ============================================================================ -// Mock Factory Functions -// ============================================================================ - -function createMockOAuthConfig(overrides: Partial = {}): TriggerOAuthConfig { - return { - configured: true, - custom_configured: false, - custom_enabled: false, - system_configured: true, - redirect_uri: 'https://example.com/oauth/callback', - params: { - client_id: 'default-client-id', - client_secret: 'default-client-secret', - }, - oauth_client_schema: [ - { - name: 'client_id', - type: 'text-input' as unknown, - required: true, - label: { 'en-US': 'Client ID' } as unknown, - }, - { - name: 'client_secret', - type: 'secret-input' as unknown, - required: true, - label: { 'en-US': 'Client Secret' } as unknown, - }, - ] as TriggerOAuthConfig['oauth_client_schema'], - ...overrides, - } -} - -function createMockSubscriptionBuilder( - overrides: Partial = {}, -): TriggerSubscriptionBuilder { - return { - id: 'builder-123', - name: 'Test Builder', - provider: 'test-provider', - credential_type: TriggerCredentialType.Oauth2, - credentials: {}, - endpoint: 'https://example.com/callback', - parameters: {}, - properties: {}, - workflows_in_use: 0, - ...overrides, - } -} - -// ============================================================================ -// Mock Setup -// ============================================================================ - -const mockInitiateOAuth = vi.fn() -const mockVerifyBuilder = vi.fn() -const mockConfigureOAuth = vi.fn() -const mockDeleteOAuth = vi.fn() - -vi.mock('@/service/use-triggers', () => ({ - useInitiateTriggerOAuth: () => ({ - mutate: mockInitiateOAuth, - }), - useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ - mutate: mockVerifyBuilder, - }), - useConfigureTriggerOAuth: () => ({ - mutate: mockConfigureOAuth, - }), - useDeleteTriggerOAuth: () => ({ - mutate: mockDeleteOAuth, - }), -})) - -const mockOpenOAuthPopup = vi.fn() -vi.mock('@/hooks/use-oauth', () => ({ - openOAuthPopup: (url: string, callback: (data: unknown) => void) => - mockOpenOAuthPopup(url, callback), -})) - -const mockToastNotify = vi.fn() -vi.mock('@/app/notifications', () => ({ - toast: Object.assign( - (message: string, options?: { type?: string }) => - mockToastNotify({ type: options?.type, message }), - { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - ), -})) - -// ============================================================================ -// Test Suites -// ============================================================================ - -describe('useOAuthClientState', () => { - const defaultParams = { - oauthConfig: createMockOAuthConfig(), - providerName: 'test-provider', - onOpenChange: vi.fn(), - showOAuthCreateModal: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('Initial State', () => { - it('should default to Default client type when system_configured is true', () => { - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - expect(result.current.clientType).toBe(ClientTypeEnum.Default) - }) - - it('should default to Custom client type when system_configured is false', () => { - const config = createMockOAuthConfig({ system_configured: false }) - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - }), - ) - - expect(result.current.clientType).toBe(ClientTypeEnum.Custom) - }) - - it('should have undefined authorizationStatus initially', () => { - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - expect(result.current.authorizationStatus).toBeUndefined() - }) - - it('should provide clientFormRef', () => { - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - expect(result.current.clientFormRef).toBeDefined() - expect(result.current.clientFormRef.current).toBeNull() - }) - }) - - describe('OAuth Client Schema', () => { - it('should compute schema with default values from params', () => { - const config = createMockOAuthConfig({ - params: { - client_id: 'my-client-id', - client_secret: 'my-secret', - }, - }) - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - }), - ) - - expect(result.current.oauthClientSchema).toHaveLength(2) - expect(result.current.oauthClientSchema[0]!.default).toBe('my-client-id') - expect(result.current.oauthClientSchema[1]!.default).toBe('my-secret') - }) - - it('should return empty array when oauth_client_schema is empty', () => { - const config = createMockOAuthConfig({ - oauth_client_schema: [], - }) - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - }), - ) - - expect(result.current.oauthClientSchema).toEqual([]) - }) - - it('should return empty array when params is undefined', () => { - const config = createMockOAuthConfig({ - params: undefined as unknown as TriggerOAuthConfig['params'], - }) - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - }), - ) - - expect(result.current.oauthClientSchema).toEqual([]) - }) - - it('should preserve original schema default when param key not found', () => { - const config = createMockOAuthConfig({ - params: { - client_id: 'only-client-id', - client_secret: '', // empty - }, - oauth_client_schema: [ - { - name: 'client_id', - type: 'text-input' as unknown, - required: true, - label: {} as unknown, - default: 'original-default', - }, - { - name: 'extra_field', - type: 'text-input' as unknown, - required: false, - label: {} as unknown, - default: 'extra-default', - }, - ] as TriggerOAuthConfig['oauth_client_schema'], - }) - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - }), - ) - - // client_id should be overridden - expect(result.current.oauthClientSchema[0]!.default).toBe('only-client-id') - // extra_field should keep original default since key not in params - expect(result.current.oauthClientSchema[1]!.default).toBe('extra-default') - }) - }) - - describe('Confirm Button Text', () => { - it('should show saveAndAuth text by default', () => { - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - expect(result.current.confirmButtonText).toBe('plugin.auth.saveAndAuth') - }) - - it('should show authorizing text when status is Pending', async () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation(() => { - // Don't resolve - stays pending - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - await waitFor(() => { - expect(result.current.confirmButtonText).toBe('pluginTrigger.modal.common.authorizing') - }) - }) - }) - - describe('setClientType', () => { - it('should update client type when called', () => { - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.setClientType(ClientTypeEnum.Custom) - }) - - expect(result.current.clientType).toBe(ClientTypeEnum.Custom) - }) - - it('should toggle between client types', () => { - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.setClientType(ClientTypeEnum.Custom) - }) - expect(result.current.clientType).toBe(ClientTypeEnum.Custom) - - act(() => { - result.current.setClientType(ClientTypeEnum.Default) - }) - expect(result.current.clientType).toBe(ClientTypeEnum.Default) - }) - }) - - describe('handleRemove', () => { - it('should call deleteOAuth with provider name', () => { - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleRemove() - }) - - expect(mockDeleteOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) - }) - - it('should call onOpenChange and show success toast on success', () => { - mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => onSuccess()) - - const onOpenChange = vi.fn() - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - onOpenChange, - }), - ) - - act(() => { - result.current.handleRemove() - }) - - expect(onOpenChange).toHaveBeenCalledWith(false) - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'pluginTrigger.modal.oauth.remove.success', - }) - }) - - it('shows the HTTP error message when removal fails', () => { - const error = createHttpError(new Response(null, { status: 403 }), { - message: 'Delete failed', - }) - mockDeleteOAuth.mockImplementation((provider, { onError }) => { - onError(error) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleRemove() - }) - - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'Delete failed', - }) - }) - }) - - describe('handleSave', () => { - it('should call configureOAuth with enabled: false for Default type', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(false) - }) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'test-provider', - enabled: false, - }), - expect.any(Object), - ) - }) - - it('should call configureOAuth with enabled: true for Custom type', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - - const config = createMockOAuthConfig({ system_configured: false }) - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - oauthConfig: config, - }), - ) - - // Mock the form ref - const mockFormRef = { - getFormValues: () => ({ - values: { client_id: 'new-id', client_secret: 'new-secret' }, - isCheckValidated: true, - }), - } - // @ts-expect-error - mocking ref - result.current.clientFormRef.current = mockFormRef - - act(() => { - result.current.handleSave(false) - }) - - expect(mockConfigureOAuth).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: true, - }), - expect.any(Object), - ) - }) - - it('should show success toast and call onOpenChange when needAuth is false', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - const onOpenChange = vi.fn() - - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - onOpenChange, - }), - ) - - act(() => { - result.current.handleSave(false) - }) - - expect(onOpenChange).toHaveBeenCalledWith(false) - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'pluginTrigger.modal.oauth.save.success', - }) - }) - - it('should trigger authorization when needAuth is true', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - expect(mockInitiateOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object)) - }) - }) - - describe('handleAuthorization', () => { - it('should set status to Pending and call initiateOAuth', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation(() => {}) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending) - expect(mockInitiateOAuth).toHaveBeenCalled() - }) - - it('should open OAuth popup on success', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - expect(mockOpenOAuthPopup).toHaveBeenCalledWith( - 'https://oauth.example.com/authorize', - expect.any(Function), - ) - }) - - it('should set status to Failed and show error toast on error', () => { - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onError }) => { - onError(new Error('OAuth failed')) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Failed) - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'pluginTrigger.modal.oauth.authorization.authFailed', - }) - }) - - it('should call onOpenChange and showOAuthCreateModal on callback success', () => { - const onOpenChange = vi.fn() - const showOAuthCreateModal = vi.fn() - const builder = createMockSubscriptionBuilder() - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: builder, - }) - }) - mockOpenOAuthPopup.mockImplementation((url, callback) => { - callback({ success: true }) - }) - - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - onOpenChange, - showOAuthCreateModal, - }), - ) - - act(() => { - result.current.handleSave(true) - }) - - expect(onOpenChange).toHaveBeenCalledWith(false) - expect(showOAuthCreateModal).toHaveBeenCalledWith(builder) - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'success', - message: 'pluginTrigger.modal.oauth.authorization.authSuccess', - }) - }) - - it('should not call callbacks when OAuth callback returns falsy', () => { - const onOpenChange = vi.fn() - const showOAuthCreateModal = vi.fn() - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockOpenOAuthPopup.mockImplementation((url, callback) => { - callback(null) - }) - - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - onOpenChange, - showOAuthCreateModal, - }), - ) - - act(() => { - result.current.handleSave(true) - }) - - expect(onOpenChange).not.toHaveBeenCalled() - expect(showOAuthCreateModal).not.toHaveBeenCalled() - }) - }) - - describe('Polling Effect', () => { - it('should start polling after authorization starts', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { - onSuccess({ verified: false }) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - // Advance timer to trigger first poll - await act(async () => { - vi.advanceTimersByTime(3000) - }) - - expect(mockVerifyBuilder).toHaveBeenCalled() - - vi.useRealTimers() - }) - - it('should set status to Success when verified', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { - onSuccess({ verified: true }) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - await act(async () => { - vi.advanceTimersByTime(3000) - }) - - await waitFor(() => { - expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Success) - }) - - vi.useRealTimers() - }) - - it('should continue polling on error', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockVerifyBuilder.mockImplementation((params, { onError }) => { - onError(new Error('Verify failed')) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - await act(async () => { - vi.advanceTimersByTime(3000) - }) - - expect(mockVerifyBuilder).toHaveBeenCalled() - // Status should still be Pending - expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending) - - vi.useRealTimers() - }) - - it('should stop polling when verified', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - - mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) - mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { - onSuccess({ - authorization_url: 'https://oauth.example.com/authorize', - subscription_builder: createMockSubscriptionBuilder(), - }) - }) - mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { - onSuccess({ verified: true }) - }) - - const { result } = renderHook(() => useOAuthClientState(defaultParams)) - - act(() => { - result.current.handleSave(true) - }) - - // First poll - should verify - await act(async () => { - vi.advanceTimersByTime(3000) - }) - - expect(mockVerifyBuilder).toHaveBeenCalledTimes(1) - - // Second poll - should not happen as interval is cleared - await act(async () => { - vi.advanceTimersByTime(3000) - }) - - // Still only 1 call because polling stopped - expect(mockVerifyBuilder).toHaveBeenCalledTimes(1) - - vi.useRealTimers() - }) - }) - - describe('Edge Cases', () => { - it('should handle undefined oauthConfig', () => { - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - oauthConfig: undefined, - }), - ) - - expect(result.current.clientType).toBe(ClientTypeEnum.Custom) - expect(result.current.oauthClientSchema).toEqual([]) - }) - - it('should handle empty providerName', () => { - const { result } = renderHook(() => - useOAuthClientState({ - ...defaultParams, - providerName: '', - }), - ) - - // Should not throw - expect(result.current.clientType).toBe(ClientTypeEnum.Default) - }) - }) -}) - -describe('Enum Exports', () => { - it('should export AuthorizationStatusEnum', () => { - expect(AuthorizationStatusEnum.Pending).toBe('pending') - expect(AuthorizationStatusEnum.Success).toBe('success') - expect(AuthorizationStatusEnum.Failed).toBe('failed') - }) - - it('should export ClientTypeEnum', () => { - expect(ClientTypeEnum.Default).toBe('default') - expect(ClientTypeEnum.Custom).toBe('custom') - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx index cee20155d1bf9e..8f1094ef1ce21a 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/__tests__/index.spec.tsx @@ -1,1755 +1,147 @@ -import type { ReactNode } from 'react' -import type { PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { FormTypeEnum } from '@/app/components/base/form/types' -import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types' -import { ApiKeyEditModal } from '../apikey-edit-modal' +import { toast } from '@/app/notifications' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { EditModal } from '../index' -import { ManualEditModal } from '../manual-edit-modal' -import { OAuthEditModal } from '../oauth-edit-modal' -// ==================== Mock Setup ==================== - -const mockToastNotify = vi.fn() -vi.mock('@/app/notifications', () => ({ - toast: Object.assign( - (message: string, options?: { type?: string }) => - mockToastNotify({ type: options?.type, message }), - { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - ), -})) - -const mockParsePluginErrorMessage = vi.fn() -vi.mock('@/utils/error-parser', () => ({ - parsePluginErrorMessage: (error: unknown) => mockParsePluginErrorMessage(error), -})) - -// Schema types -type SubscriptionSchema = { - name: string - label: Record - type: string - required: boolean - default?: string - description?: Record - multiple: boolean - auto_generate: null - template: null - scope: null - min: null - max: null - precision: null -} - -type CredentialSchema = { - name: string - label: Record - type: string - required: boolean - default?: string - help?: Record -} - -const mockPluginStoreDetail = { - plugin_id: 'test-plugin-id', - provider: 'test-provider', - declaration: { - trigger: { - subscription_schema: [] as SubscriptionSchema[], - subscription_constructor: { - credentials_schema: [] as CredentialSchema[], - parameters: [] as SubscriptionSchema[], - oauth_schema: { client_schema: [], credentials_schema: [] }, +const requests = vi.hoisted(() => ({ update: vi.fn(), verify: vi.fn(), refetch: vi.fn() })) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.triggerProvider.bySubscriptionId.subscriptions.update.post': + requests.update, + 'workspaces.current.triggerProvider.byProvider.subscriptions.verify.bySubscriptionId.post': + requests.verify, + }), + } +}) +vi.mock('../../../store', () => ({ + usePluginStore: () => ({ + plugin_id: 'test/plugin', + provider: 'provider-a', + declaration: { + trigger: { + subscription_schema: [ + { name: 'channel', label: 'Channel', type: FormTypeEnum.textInput, required: true }, + ], + subscription_constructor: { + credentials_schema: [ + { name: 'api_key', label: 'API key', type: FormTypeEnum.secretInput, required: true }, + ], + parameters: [ + { name: 'repo', label: 'Repository', type: FormTypeEnum.textInput, required: true }, + ], + }, }, }, - }, -} - -vi.mock('../../../store', () => ({ - usePluginStore: (selector: (state: { detail: typeof mockPluginStoreDetail }) => unknown) => - selector({ detail: mockPluginStoreDetail }), -})) - -const getCancelButton = () => screen.getByRole('button', { name: /common\.operation\.cancel/i }) -const getConfirmButton = () => - screen.getByRole('button', { - name: /common\.operation\.(save|saving)|pluginTrigger\.modal\.common\.verify/i, - }) -const getBackButton = () => - screen.getByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) -const queryBackButton = () => - screen.queryByRole('button', { name: /pluginTrigger\.modal\.common\.back/i }) - -const mockRefetch = vi.fn() -vi.mock('../../use-subscription-list', () => ({ - useSubscriptionList: () => ({ refetch: mockRefetch }), -})) - -const mockUpdateSubscription = vi.fn() -const mockVerifyCredentials = vi.fn() -let mockIsUpdating = false -let mockIsVerifying = false - -vi.mock('@/service/use-triggers', () => ({ - useUpdateTriggerSubscription: () => ({ - mutate: mockUpdateSubscription, - isPending: mockIsUpdating, - }), - useVerifyTriggerSubscription: () => ({ - mutate: mockVerifyCredentials, - isPending: mockIsVerifying, }), })) - -vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({ - ReadmeEntrance: ({ pluginDetail }: { pluginDetail: PluginDetail }) => ( -
- ReadmeEntrance -
- ), -})) - -vi.mock('@/app/components/base/encrypted-bottom', () => ({ - EncryptedBottom: () =>
EncryptedBottom
, - EncryptedFooter: ({ children }: { children: ReactNode }) => <>{children}, -})) - -// Form values storage keyed by form identifier -const formValuesMap = new Map< - string, - { values: Record; isCheckValidated: boolean } ->() - -// Track which modal is being tested to properly identify forms -let currentModalType: 'manual' | 'oauth' | 'apikey' = 'manual' - -// Helper to get form identifier based on schemas and context -const getFormId = (schemas: Array<{ name: string }>, preventDefaultSubmit?: boolean): string => { - if (preventDefaultSubmit) return 'credentials' - if (schemas.some((s) => s.name === 'subscription_name')) { - // For ApiKey modal step 2, basic form only has subscription_name and callback_url - if (currentModalType === 'apikey' && schemas.length === 2) return 'basic' - // For ManualEditModal and OAuthEditModal, the main form always includes subscription_name - return 'main' - } - return 'parameters' -} - -vi.mock('@/app/components/base/form/components/base', () => ({ - BaseForm: vi.fn().mockImplementation(({ formSchemas, ref, preventDefaultSubmit }) => { - const formId = getFormId(formSchemas || [], preventDefaultSubmit) - if (ref) { - ref.current = { - getFormValues: () => formValuesMap.get(formId) || { values: {}, isCheckValidated: true }, - } - } - return ( -
- {formSchemas?.map( - (schema: { - name: string - type: string - default?: unknown - dynamicSelectParams?: unknown - fieldClassName?: string - labelClassName?: string - }) => ( -
- {schema.name} -
- ), - )} -
- ) - }), +vi.mock('../../use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: requests.refetch }), })) - -// ==================== Test Utilities ==================== - -const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ - id: 'test-subscription-id', - name: 'Test Subscription', - provider: 'test-provider', - credential_type: TriggerCredentialType.Unauthorized, - credentials: {}, - endpoint: 'https://example.com/webhook', - parameters: {}, - properties: {}, +vi.mock('@/app/notifications', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +const subscription: TriggerSubscription = { + id: 'subscription-1', + name: 'Original', + provider: 'provider-a', + credential_type: TriggerCredentialType.ApiKey, + credentials: { api_key: '[__HIDDEN__]' }, + endpoint: 'https://example.com/callback', + parameters: { repo: 'dify' }, + properties: { channel: 'releases' }, workflows_in_use: 0, - ...overrides, +} +const save = () => screen.getByRole('button', { name: 'common.operation.save' }) +const subscriptionName = /pluginTrigger.modal.form.subscriptionName.label/ +beforeEach(() => { + requests.update.mockResolvedValue({}) + requests.verify.mockResolvedValue({ verified: true }) }) -const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ - id: 'test-plugin-id', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - name: 'Test Plugin', - plugin_id: 'test-plugin', - plugin_unique_identifier: 'test-plugin-unique-id', - declaration: { - plugin_unique_identifier: 'test-plugin-unique-id', - version: '1.0.0', - author: 'Test Author', - icon: 'test-icon', - name: 'test-plugin', - category: PluginCategoryEnum.trigger, - label: {} as Record, - description: {} as Record, - created_at: '2024-01-01T00:00:00Z', - resource: {}, - plugins: [], - verified: true, - endpoint: { settings: [], endpoints: [] }, - model: {}, - tags: [], - agent_strategy: null, - meta: { version: '1.0.0' }, - trigger: { - events: [], - identity: { - author: 'Test Author', - name: 'test-trigger', - label: {} as Record, - description: {} as Record, - icon: 'test-icon', - tags: [], - }, - subscription_constructor: { - credentials_schema: [], - oauth_schema: { client_schema: [], credentials_schema: [] }, - parameters: [], - }, - subscription_schema: [], - }, +it.each([ + [TriggerCredentialType.Unauthorized, 'Channel', 'properties', 'channel'], + [TriggerCredentialType.Oauth2, 'Repository', 'parameters', 'repo'], +] as const)( + 'validates and saves edited %s subscription fields', + async (credentialType, label, payloadKey, field) => { + const user = userEvent.setup() + const onClose = vi.fn() + renderWithConsoleQuery( + , + ) + const name = screen.getByRole('textbox', { name: subscriptionName }) + await user.clear(name) + await user.click(save()) + expect(requests.update).not.toHaveBeenCalled() + await user.type(name, 'Renamed') + const input = screen.getByRole('textbox', { name: new RegExp(label) }) + await user.clear(input) + await user.type(input, 'updated') + await user.click(save()) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(requests.update).toHaveBeenCalledExactlyOnceWith({ + params: { subscription_id: 'subscription-1' }, + body: { name: 'Renamed', [payloadKey]: { [field]: 'updated' } }, + }) + expect(requests.refetch).toHaveBeenCalledOnce() }, - installation_id: 'test-installation-id', - tenant_id: 'test-tenant-id', - endpoints_setups: 0, - endpoints_active: 0, - version: '1.0.0', - latest_version: '1.0.0', - latest_unique_identifier: 'test-plugin-unique-id', - source: PluginSource.marketplace, - status: 'active' as const, - deprecated_reason: '', - alternative_plugin_id: '', - ...overrides, -}) - -const createSchemaField = ( - name: string, - type: string = 'string', - overrides = {}, -): SubscriptionSchema => ({ - name, - label: { en_US: name }, - type, - required: true, - multiple: false, - auto_generate: null, - template: null, - scope: null, - min: null, - max: null, - precision: null, - ...overrides, -}) - -const createCredentialSchema = ( - name: string, - type: string = 'secret-input', - overrides = {}, -): CredentialSchema => ({ - name, - label: { en_US: name }, - type, - required: true, - ...overrides, -}) - -const resetMocks = () => { - mockPluginStoreDetail.plugin_id = 'test-plugin-id' - mockPluginStoreDetail.provider = 'test-provider' - mockPluginStoreDetail.declaration.trigger.subscription_schema = [] - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [] - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [] - formValuesMap.clear() - // Set default form values - formValuesMap.set('main', { values: { subscription_name: 'Test' }, isCheckValidated: true }) - formValuesMap.set('basic', { values: { subscription_name: 'Test' }, isCheckValidated: true }) - formValuesMap.set('credentials', { values: {}, isCheckValidated: true }) - formValuesMap.set('parameters', { values: {}, isCheckValidated: true }) - // Reset pending states - mockIsUpdating = false - mockIsVerifying = false -} - -// ==================== Tests ==================== - -describe('Edit Modal Components', () => { - beforeEach(() => { - vi.clearAllMocks() - resetMocks() - }) - - // ==================== EditModal (Router) Tests ==================== - - describe('EditModal (Router)', () => { - it.each([ - { type: TriggerCredentialType.Unauthorized, name: 'ManualEditModal' }, - { type: TriggerCredentialType.Oauth2, name: 'OAuthEditModal' }, - { type: TriggerCredentialType.ApiKey, name: 'ApiKeyEditModal' }, - ])('should render $name for $type credential type', ({ type }) => { - render( - , - ) - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('should render nothing for unknown credential type', () => { - const { container } = render( - , - ) - expect(container).toBeEmptyDOMElement() - }) - - it('should pass pluginDetail to child modal', () => { - const pluginDetail = createPluginDetail({ id: 'custom-plugin' }) - render( - , - ) - expect(screen.getByTestId('readme-entrance')).toHaveAttribute( - 'data-plugin-id', - 'custom-plugin', - ) - }) - }) - - // ==================== ManualEditModal Tests ==================== - - describe('ManualEditModal', () => { - beforeEach(() => { - currentModalType = 'manual' - }) - - const createProps = (overrides = {}) => ({ - onClose: vi.fn(), - subscription: createSubscription(), - ...overrides, - }) - - describe('Rendering', () => { - it('should render modal with correct title', () => { - render() - expect( - screen.getByRole('heading', { - name: 'pluginTrigger.subscription.list.item.actions.edit.title', - }), - ).toBeInTheDocument() - }) - - it('should render ReadmeEntrance when pluginDetail is provided', () => { - render() - expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() - }) - - it('should not render ReadmeEntrance when pluginDetail is not provided', () => { - render() - expect(screen.queryByTestId('readme-entrance')).not.toBeInTheDocument() - }) - - it('should render subscription_name and callback_url fields', () => { - render() - expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - - it('should render properties schema fields from store', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('custom_field'), - createSchemaField('another_field', 'number'), - ] - render() - expect(screen.getByTestId('form-field-custom_field')).toBeInTheDocument() - expect(screen.getByTestId('form-field-another_field')).toBeInTheDocument() - }) - }) - - describe('Form Schema Default Values', () => { - it('should use subscription name as default', () => { - render( - , - ) - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( - 'data-field-default', - 'My Sub', - ) - }) - - it('should use endpoint as callback_url default', () => { - render( - , - ) - expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute( - 'data-field-default', - 'https://test.com', - ) - }) - - it('should use empty string when endpoint is empty', () => { - render( - , - ) - expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute( - 'data-field-default', - '', - ) - }) - - it('should use subscription properties as defaults for custom fields', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('custom'), - ] - render( - , - ) - expect(screen.getByTestId('form-field-custom')).toHaveAttribute( - 'data-field-default', - 'value', - ) - }) - - it('should use schema default when subscription property is missing', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('custom', 'string', { default: 'schema_default' }), - ] - render() - expect(screen.getByTestId('form-field-custom')).toHaveAttribute( - 'data-field-default', - 'schema_default', - ) - }) - }) - - describe('Confirm Button Text', () => { - it('should show "save" when not updating', () => { - render() - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - }) - - describe('User Interactions', () => { - it('should call onClose when cancel button is clicked', () => { - const onClose = vi.fn() - render() - fireEvent.click(getCancelButton()) - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call onClose when close button is clicked', async () => { - const user = userEvent.setup() - const onClose = vi.fn() - render() - await user.click(screen.getByRole('button', { name: 'common.operation.close' })) - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call updateSubscription when confirm is clicked with valid form', () => { - formValuesMap.set('main', { - values: { subscription_name: 'New Name' }, - isCheckValidated: true, - }) - render() - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ subscriptionId: 'test-subscription-id', name: 'New Name' }), - expect.any(Object), - ) - }) - - it('should not call updateSubscription when form validation fails', () => { - formValuesMap.set('main', { values: {}, isCheckValidated: false }) - render() - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).not.toHaveBeenCalled() - }) - }) - - describe('Properties Change Detection', () => { - it('should not send properties when unchanged', () => { - const subscription = createSubscription({ properties: { custom: 'value' } }) - formValuesMap.set('main', { - values: { subscription_name: 'Name', callback_url: '', custom: 'value' }, - isCheckValidated: true, - }) - render() - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ properties: undefined }), - expect.any(Object), - ) - }) - - it('should send properties when changed', () => { - const subscription = createSubscription({ properties: { custom: 'old' } }) - formValuesMap.set('main', { - values: { subscription_name: 'Name', callback_url: '', custom: 'new' }, - isCheckValidated: true, - }) - render() - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ properties: { custom: 'new' } }), - expect.any(Object), - ) - }) - }) - - describe('Update Callbacks', () => { - it('should call onClose on success', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess()) - const onClose = vi.fn() - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(onClose).toHaveBeenCalled() - }) - expect(mockRefetch).toHaveBeenCalled() - }) - - it('should show error toast with Error message on failure', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Custom error'))) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'Custom error', - }), - ) - }) - }) - - it('should use error.message when available', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Object error'))) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'Object error', - }), - ) - }) - }) - - it('should use fallback message when error has no message', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({})) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - }), - ) - }) - }) - - it('should use fallback message when error is null', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(null)) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - }), - ) - }) - }) - - it('should use fallback when error.message is not a string', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 123 })) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - }), - ) - }) - }) - - it('should use fallback when error.message is empty string', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: '' })) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - }), - ) - }) - }) - }) - - describe('normalizeFormType in ManualEditModal', () => { - it('should normalize number type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('num_field', 'number'), - ] - render() - expect(screen.getByTestId('form-field-num_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textNumber, - ) - }) - - it('should normalize select type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('sel_field', 'select'), - ] - render() - expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.select, - ) - }) - - it('should return textInput for unknown type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('unknown_field', 'unknown-custom-type'), - ] - render() - expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textInput, - ) - }) - }) - - describe('Button Text State', () => { - it('should show saving text when isUpdating is true', () => { - mockIsUpdating = true - render() - expect(getConfirmButton()).toHaveTextContent('common.operation.saving') - }) - }) - }) - - // ==================== OAuthEditModal Tests ==================== - - describe('OAuthEditModal', () => { - beforeEach(() => { - currentModalType = 'oauth' - }) - - const createProps = (overrides = {}) => ({ - onClose: vi.fn(), - subscription: createSubscription({ credential_type: TriggerCredentialType.Oauth2 }), - ...overrides, - }) - - describe('Rendering', () => { - it('should render modal with correct title', () => { - render() - expect( - screen.getByRole('heading', { - name: 'pluginTrigger.subscription.list.item.actions.edit.title', - }), - ).toBeInTheDocument() - }) - - it('should render ReadmeEntrance when pluginDetail is provided', () => { - render() - expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() - }) - - it('should render parameters schema fields from store', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('oauth_param'), - ] - render() - expect(screen.getByTestId('form-field-oauth_param')).toBeInTheDocument() - }) - }) - - describe('Form Schema Default Values', () => { - it('should use subscription parameters as defaults', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('channel'), - ] - render( - , - ) - expect(screen.getByTestId('form-field-channel')).toHaveAttribute( - 'data-field-default', - 'general', - ) - }) - }) - - describe('Dynamic Select Support', () => { - it('should add dynamicSelectParams for dynamic-select type fields', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('dynamic_field', FormTypeEnum.dynamicSelect), - ] - render() - expect(screen.getByTestId('form-field-dynamic_field')).toHaveAttribute( - 'data-has-dynamic-select', - 'true', - ) - }) - - it('should not add dynamicSelectParams for non-dynamic-select fields', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('text_field', 'string'), - ] - render() - expect(screen.getByTestId('form-field-text_field')).toHaveAttribute( - 'data-has-dynamic-select', - 'false', - ) - }) - }) - - describe('Boolean Field Styling', () => { - it('should add fieldClassName and labelClassName for boolean type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('bool_field', FormTypeEnum.boolean), - ] - render() - expect(screen.getByTestId('form-field-bool_field')).toHaveAttribute( - 'data-field-class', - 'flex items-center justify-between', - ) - expect(screen.getByTestId('form-field-bool_field')).toHaveAttribute( - 'data-label-class', - 'mb-0', - ) - }) - }) - - describe('Parameters Change Detection', () => { - it('should not send parameters when unchanged', () => { - formValuesMap.set('main', { - values: { subscription_name: 'Name', callback_url: '', channel: 'general' }, - isCheckValidated: true, - }) - render( - , - ) - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ parameters: undefined }), - expect.any(Object), - ) - }) - - it('should send parameters when changed', () => { - formValuesMap.set('main', { - values: { subscription_name: 'Name', callback_url: '', channel: 'new' }, - isCheckValidated: true, - }) - render( - , - ) - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { channel: 'new' } }), - expect.any(Object), - ) - }) - }) - - describe('Update Callbacks', () => { - it('should call onClose on success', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess()) - const onClose = vi.fn() - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(onClose).toHaveBeenCalled() - }) - }) - - it('should show error toast on failure', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Failed'))) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - }) - - it('should use fallback when error.message is not a string', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 123 })) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - }), - ) - }) - }) - - it('should use fallback when error.message is empty string', async () => { - formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: '' })) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.subscription.list.item.actions.edit.error', - }), - ) - }) - }) - }) - - describe('Form Validation', () => { - it('should not call updateSubscription when form validation fails', () => { - formValuesMap.set('main', { values: {}, isCheckValidated: false }) - render() - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).not.toHaveBeenCalled() - }) - }) - - describe('normalizeFormType in OAuthEditModal', () => { - it('should normalize number type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('num_field', 'number'), - ] - render() - expect(screen.getByTestId('form-field-num_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textNumber, - ) - }) - - it('should normalize integer type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('int_field', 'integer'), - ] - render() - expect(screen.getByTestId('form-field-int_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textNumber, - ) - }) - - it('should normalize select type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('sel_field', 'select'), - ] - render() - expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.select, - ) - }) - - it('should normalize password type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('pwd_field', 'password'), - ] - render() - expect(screen.getByTestId('form-field-pwd_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.secretInput, - ) - }) - - it('should return textInput for unknown type', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('unknown_field', 'custom-unknown-type'), - ] - render() - expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textInput, - ) - }) - }) - - describe('Button Text State', () => { - it('should show saving text when isUpdating is true', () => { - mockIsUpdating = true - render() - expect(getConfirmButton()).toHaveTextContent('common.operation.saving') - }) - }) - }) - - // ==================== ApiKeyEditModal Tests ==================== - - describe('ApiKeyEditModal', () => { - beforeEach(() => { - currentModalType = 'apikey' - }) - - const createProps = (overrides = {}) => ({ - onClose: vi.fn(), - subscription: createSubscription({ credential_type: TriggerCredentialType.ApiKey }), - ...overrides, - }) - - // Setup credentials schema for ApiKeyEditModal tests - const setupCredentialsSchema = () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('api_key'), - ] - } - - describe('Rendering - Step 1 (Credentials)', () => { - it('should render modal with correct title', () => { - render() - expect( - screen.getByRole('heading', { - name: 'pluginTrigger.subscription.list.item.actions.edit.title', - }), - ).toBeInTheDocument() - }) - - it('should render EncryptedBottom in credentials step', () => { - render() - expect(screen.getByTestId('encrypted-bottom')).toBeInTheDocument() - }) - - it('should render credentials form fields', () => { - setupCredentialsSchema() - render() - expect(screen.getByTestId('form-field-api_key')).toBeInTheDocument() - }) - - it('should show verify button text in credentials step', () => { - render() - expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.verify') - }) - - it('should not show extra button (back) in credentials step', () => { - render() - expect(queryBackButton()).not.toBeInTheDocument() - }) - - it('should render ReadmeEntrance when pluginDetail is provided', () => { - render() - expect(screen.getByTestId('readme-entrance')).toBeInTheDocument() - }) - }) - - describe('Credentials Form Defaults', () => { - it('should use subscription credentials as defaults', () => { - setupCredentialsSchema() - render( - , - ) - expect(screen.getByTestId('form-field-api_key')).toHaveAttribute( - 'data-field-default', - '[__HIDDEN__]', - ) - }) - }) - - describe('Credential Verification', () => { - beforeEach(() => { - setupCredentialsSchema() - }) - - it('should call verifyCredentials when confirm clicked in credentials step', () => { - formValuesMap.set('credentials', { - values: { api_key: 'test-key' }, - isCheckValidated: true, - }) - render() - fireEvent.click(getConfirmButton()) - expect(mockVerifyCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'test-provider', - subscriptionId: 'test-subscription-id', - credentials: { api_key: 'test-key' }, - }), - expect.any(Object), - ) - }) - - it('should not call verifyCredentials when form validation fails', () => { - formValuesMap.set('credentials', { values: {}, isCheckValidated: false }) - render() - fireEvent.click(getConfirmButton()) - expect(mockVerifyCredentials).not.toHaveBeenCalled() - }) - - it('should show success toast and move to step 2 on successful verification', async () => { - formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'success', - message: 'pluginTrigger.modal.apiKey.verify.success', - }), - ) - }) - // Should now be in step 2 - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - it('should show error toast on verification failure', async () => { - formValuesMap.set('credentials', { values: { api_key: 'bad-key' }, isCheckValidated: true }) - mockParsePluginErrorMessage.mockReturnValue('Invalid API key') - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onError(new Error('Invalid'))) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'Invalid API key', - }), - ) - }) - }) - - it('should use fallback error message when parsePluginErrorMessage returns null', async () => { - formValuesMap.set('credentials', { values: { api_key: 'bad-key' }, isCheckValidated: true }) - mockParsePluginErrorMessage.mockReturnValue(null) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onError(new Error('Invalid'))) - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'pluginTrigger.modal.apiKey.verify.error', - }), - ) - }) - }) - - it('should set verifiedCredentials to null when all credentials are hidden', async () => { - formValuesMap.set('credentials', { - values: { api_key: '[__HIDDEN__]' }, - isCheckValidated: true, - }) - formValuesMap.set('basic', { - values: { subscription_name: 'Name' }, - isCheckValidated: true, - }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - render() - - // Verify credentials - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - // Update subscription - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ credentials: undefined }), - expect.any(Object), - ) - }) - }) - - describe('Step 2 (Configuration)', () => { - beforeEach(() => { - setupCredentialsSchema() - formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - }) - - it('should show save button text in configuration step', async () => { - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - }) - - it('should show extra button (back) in configuration step', async () => { - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getBackButton()).toBeInTheDocument() - expect(getBackButton()).toHaveTextContent('pluginTrigger.modal.common.back') - }) - }) - - it('should not show EncryptedBottom in configuration step', async () => { - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(screen.queryByTestId('encrypted-bottom')).not.toBeInTheDocument() - }) - }) - - it('should render basic form fields in step 2', async () => { - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument() - expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument() - }) - }) - - it('should render parameters form when parameters schema exists', async () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('param1'), - ] - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(screen.getByTestId('form-field-param1')).toBeInTheDocument() - }) - }) - }) - - describe('Back Button', () => { - beforeEach(() => { - setupCredentialsSchema() - }) - - it('should go back to credentials step when back button is clicked', async () => { - formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - render() - - // Go to step 2 - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getBackButton()).toBeInTheDocument() - }) - - // Click back - fireEvent.click(getBackButton()) - - // Should be back in step 1 - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.verify') - }) - expect(queryBackButton()).not.toBeInTheDocument() - }) - - it('should go back to credentials step when clicking step indicator', async () => { - formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - render() - - // Go to step 2 - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - // Find and click the step indicator (first step text should be clickable in step 2) - const stepIndicator = screen.getByText('pluginTrigger.modal.steps.verify') - fireEvent.click(stepIndicator) - - // Should be back in step 1 - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('pluginTrigger.modal.common.verify') - }) - }) - }) - - describe('Update Subscription', () => { - beforeEach(() => { - setupCredentialsSchema() - formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - }) - - it('should call updateSubscription with verified credentials', async () => { - formValuesMap.set('basic', { - values: { subscription_name: 'Name' }, - isCheckValidated: true, - }) - render() - - // Step 1: Verify - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - // Step 2: Update - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ - subscriptionId: 'test-subscription-id', - name: 'Name', - credentials: { api_key: 'new-key' }, - }), - expect.any(Object), - ) - }) - - it('should not call updateSubscription when basic form validation fails', async () => { - formValuesMap.set('basic', { values: {}, isCheckValidated: false }) - render() - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).not.toHaveBeenCalled() - }) - - it('should close on successful update', async () => { - formValuesMap.set('basic', { - values: { subscription_name: 'Name' }, - isCheckValidated: true, - }) - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess()) - const onClose = vi.fn() - render() - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - fireEvent.click(getConfirmButton()) - expect(mockRefetch).toHaveBeenCalled() - expect(onClose).toHaveBeenCalled() - }) - - it('should show error toast on update failure', async () => { - formValuesMap.set('basic', { - values: { subscription_name: 'Name' }, - isCheckValidated: true, - }) - mockParsePluginErrorMessage.mockReturnValue('Update failed') - mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Failed'))) - render() - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: 'Update failed', - }), - ) - }) - }) - }) - - describe('Parameters Change Detection', () => { - beforeEach(() => { - setupCredentialsSchema() - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('param1'), - ] - formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - }) - - it('should not send parameters when unchanged', async () => { - formValuesMap.set('basic', { - values: { subscription_name: 'Name' }, - isCheckValidated: true, - }) - formValuesMap.set('parameters', { values: { param1: 'value' }, isCheckValidated: true }) - render( - , - ) - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ parameters: undefined }), - expect.any(Object), - ) - }) - - it('should send parameters when changed', async () => { - formValuesMap.set('basic', { - values: { subscription_name: 'Name' }, - isCheckValidated: true, - }) - formValuesMap.set('parameters', { values: { param1: 'new_value' }, isCheckValidated: true }) - render( - , - ) - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).toHaveBeenCalledWith( - expect.objectContaining({ parameters: { param1: 'new_value' } }), - expect.any(Object), - ) - }) - }) - - describe('normalizeFormType in ApiKeyEditModal', () => { - it('should normalize number type for credentials schema', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('port', 'number'), - ] - render() - expect(screen.getByTestId('form-field-port')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textNumber, - ) - }) - - it('should normalize select type for credentials schema', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('region', 'select'), - ] - render() - expect(screen.getByTestId('form-field-region')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.select, - ) - }) - - it('should normalize text type for credentials schema', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('name', 'text'), - ] - render() - expect(screen.getByTestId('form-field-name')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textInput, - ) - }) - }) - - describe('Dynamic Select in Parameters', () => { - beforeEach(() => { - setupCredentialsSchema() - formValuesMap.set('credentials', { values: { api_key: 'key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - }) - - it('should include dynamicSelectParams for dynamic-select type parameters', async () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('channel', FormTypeEnum.dynamicSelect), - ] - render() - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - expect(screen.getByTestId('form-field-channel')).toHaveAttribute( - 'data-has-dynamic-select', - 'true', - ) - }) - }) - - describe('Boolean Field Styling', () => { - beforeEach(() => { - setupCredentialsSchema() - formValuesMap.set('credentials', { values: { api_key: 'key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - }) - - it('should add special class for boolean type parameters', async () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('enabled', FormTypeEnum.boolean), - ] - render() - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - expect(screen.getByTestId('form-field-enabled')).toHaveAttribute( - 'data-field-class', - 'flex items-center justify-between', - ) - }) - }) - - describe('normalizeFormType in ApiKeyEditModal - Credentials Schema', () => { - it('should normalize password type for credentials', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('secret_key', 'password'), - ] - render() - expect(screen.getByTestId('form-field-secret_key')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.secretInput, - ) - }) - - it('should normalize secret type for credentials', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('api_secret', 'secret'), - ] - render() - expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.secretInput, - ) - }) - - it('should normalize string type for credentials', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('username', 'string'), - ] - render() - expect(screen.getByTestId('form-field-username')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textInput, - ) - }) - - it('should normalize integer type for credentials', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('timeout', 'integer'), - ] - render() - expect(screen.getByTestId('form-field-timeout')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textNumber, - ) - }) - - it('should pass through valid FormTypeEnum for credentials', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('file_field', FormTypeEnum.files), - ] - render() - expect(screen.getByTestId('form-field-file_field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.files, - ) - }) - - it('should default to textInput for unknown credential types', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [ - createCredentialSchema('custom', 'unknown-type'), - ] - render() - expect(screen.getByTestId('form-field-custom')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textInput, - ) - }) - }) - - describe('Parameters Form Validation', () => { - beforeEach(() => { - setupCredentialsSchema() - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('param1'), - ] - formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - }) - - it('should not update when parameters form validation fails', async () => { - formValuesMap.set('basic', { - values: { subscription_name: 'Name' }, - isCheckValidated: true, - }) - formValuesMap.set('parameters', { values: {}, isCheckValidated: false }) - render() - - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(getConfirmButton()).toHaveTextContent('common.operation.save') - }) - - fireEvent.click(getConfirmButton()) - expect(mockUpdateSubscription).not.toHaveBeenCalled() - }) - }) - - describe('ApiKeyEditModal without credentials schema', () => { - it('should not render credentials form when credentials_schema is empty', () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [] - render() - // Should still show the modal but no credentials form fields - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - }) - - describe('normalizeFormType in Parameters Schema', () => { - beforeEach(() => { - setupCredentialsSchema() - formValuesMap.set('credentials', { values: { api_key: 'key' }, isCheckValidated: true }) - mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess()) - }) - - it('should normalize password type for parameters', async () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('secret_param', 'password'), - ] - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(screen.getByTestId('form-field-secret_param')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.secretInput, - ) - }) - }) - - it('should normalize secret type for parameters', async () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('api_secret', 'secret'), - ] - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.secretInput, - ) - }) - }) - - it('should normalize integer type for parameters', async () => { - mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [ - createSchemaField('count', 'integer'), - ] - render() - fireEvent.click(getConfirmButton()) - await waitFor(() => { - expect(screen.getByTestId('form-field-count')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textNumber, - ) - }) - }) - }) - }) - - // ==================== normalizeFormType Tests ==================== - - describe('normalizeFormType behavior', () => { - const testCases = [ - { input: 'string', expected: FormTypeEnum.textInput }, - { input: 'text', expected: FormTypeEnum.textInput }, - { input: 'password', expected: FormTypeEnum.secretInput }, - { input: 'secret', expected: FormTypeEnum.secretInput }, - { input: 'number', expected: FormTypeEnum.textNumber }, - { input: 'integer', expected: FormTypeEnum.textNumber }, - { input: 'boolean', expected: FormTypeEnum.boolean }, - { input: 'select', expected: FormTypeEnum.select }, - ] - - testCases.forEach(({ input, expected }) => { - it(`should normalize ${input} to ${expected}`, () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('field', input), - ] - render() - expect(screen.getByTestId('form-field-field')).toHaveAttribute('data-field-type', expected) - }) - }) - - it('should return textInput for unknown types', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('field', 'unknown'), - ] - render() - expect(screen.getByTestId('form-field-field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.textInput, - ) - }) - - it('should pass through valid FormTypeEnum values', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('field', FormTypeEnum.files), - ] - render() - expect(screen.getByTestId('form-field-field')).toHaveAttribute( - 'data-field-type', - FormTypeEnum.files, - ) - }) +) + +it('verifies edited API credentials before saving them with the configuration', async () => { + const user = userEvent.setup() + const verification = createDeferred<{ verified: boolean }>() + requests.verify.mockReturnValue(verification.promise) + const onClose = vi.fn() + renderWithConsoleQuery() + const key = screen.getByLabelText(/API key/) + await user.clear(key) + await user.type(key, 'new-secret') + await user.click(screen.getByRole('button', { name: 'pluginTrigger.modal.common.verify' })) + expect( + await screen.findByRole('button', { name: 'pluginTrigger.modal.common.verifying' }), + ).toBeDisabled() + expect(requests.update).not.toHaveBeenCalled() + expect(requests.verify).toHaveBeenCalledWith( + { + params: { provider: 'provider-a', subscription_id: 'subscription-1' }, + body: { credentials: { api_key: 'new-secret' } }, + }, + { context: { silent: true } }, + ) + verification.resolve({ verified: true }) + await screen.findByRole('textbox', { name: subscriptionName }) + await user.click(save()) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(requests.update).toHaveBeenCalledWith({ + params: { subscription_id: 'subscription-1' }, + body: { name: 'Original', parameters: undefined, credentials: { api_key: 'new-secret' } }, }) +}) - // ==================== Edge Cases ==================== - - describe('Edge Cases', () => { - it('should handle empty subscription name', () => { - render() - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( - 'data-field-default', - '', - ) - }) - - it('should handle special characters in subscription data', () => { - render( - alert("xss")' })} - />, - ) - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( - 'data-field-default', - '', - ) - }) - - it('should handle Unicode characters', () => { - render( - , - ) - expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute( - 'data-field-default', - '测试订阅 🚀', - ) - }) - - it('should handle multiple schema fields', () => { - mockPluginStoreDetail.declaration.trigger.subscription_schema = [ - createSchemaField('field1', 'string'), - createSchemaField('field2', 'number'), - createSchemaField('field3', 'boolean'), - ] - render() - expect(screen.getByTestId('form-field-field1')).toBeInTheDocument() - expect(screen.getByTestId('form-field-field2')).toBeInTheDocument() - expect(screen.getByTestId('form-field-field3')).toBeInTheDocument() - }) +it('preserves unchanged hidden credentials and leaves the dialog open if saving fails', async () => { + const user = userEvent.setup() + requests.update.mockRejectedValueOnce(new Error('Save failed')) + const onClose = vi.fn() + renderWithConsoleQuery() + await user.click(screen.getByRole('button', { name: 'pluginTrigger.modal.common.verify' })) + await screen.findByRole('textbox', { name: subscriptionName }) + await user.click(save()) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Save failed')) + expect(onClose).not.toHaveBeenCalled() + expect(requests.update).toHaveBeenCalledWith({ + params: { subscription_id: 'subscription-1' }, + body: { name: 'Original', parameters: undefined, credentials: undefined }, }) + await user.click(save()) + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx index abb481a796662c..5b78e1cce7e428 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx @@ -342,6 +342,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
{credentialsFormSchemas.length > 0 && ( ({ - ToolAuthorizationSection: () => null, -})) -vi.mock('../components/tool-base-form', () => ({ ToolBaseForm: () => null })) -vi.mock('../components/tool-settings-panel', () => ({ ToolSettingsPanel: () => null })) - -vi.mock('../hooks/use-tool-selector', () => ({ - useToolSelector: () => ({ - isShow: false, - setIsShow: vi.fn(), - isShowChooseTool: false, - setIsShowChooseTool: vi.fn(), - currType: 'settings', - setCurrType: vi.fn(), - currentProvider: undefined, - currentTool: undefined, - settingsFormSchemas: [], - paramsFormSchemas: [], - showTabSlider: false, - userSettingsOnly: false, - reasoningConfigOnly: false, - manifestIcon: '', - inMarketPlace: false, - manifest: undefined, - handleSelectTool: vi.fn(), - handleSelectMultipleTool: vi.fn(), - handleDescriptionChange: vi.fn(), - handleSettingsFormChange: vi.fn(), - handleParamsFormChange: vi.fn(), - handleEnabledChange: vi.fn(), - handleAuthorizationItemClick: vi.fn(), - handleInstall: vi.fn(), - settingsValue: {}, - }), -})) - -describe('ToolSelector', () => { - it('opens an unconfigured add-tool selector and restores focus when dismissed', async () => { - const user = userEvent.setup() - - function Harness() { - const [open, setOpen] = useState(false) - return ( - Add tool} - controlledState={open} - onControlledStateChange={setOpen} - onSelect={vi.fn()} - nodeOutputVars={[]} - availableNodes={[]} - /> - ) - } - - render() - - const trigger = screen.getByRole('button', { name: 'Add tool' }) - await user.click(trigger) - - expect(trigger).toHaveAttribute('aria-expanded', 'true') - expect(screen.getByText('plugin.detailPanel.toolSelector.title')).toBeInTheDocument() - - await user.keyboard('{Escape}') - - expect(screen.queryByText('plugin.detailPanel.toolSelector.title')).not.toBeInTheDocument() - expect(trigger).toHaveFocus() - }) -}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts index f6327e14a5b402..147bfc149a1135 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-plugin-installed-check.spec.ts @@ -1,101 +1,77 @@ -import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { waitFor } from '@testing-library/react' import { PluginSource } from '@/app/components/plugins/types' +import { consoleQuery } from '@/service/console' +import { + createConsoleQueryClient, + renderHookWithConsoleQuery as renderHook, +} from '@/test/console/query-data' +import { createPluginEntity } from '@/test/fixtures/plugin' import { usePluginInstalledCheck } from '../use-plugin-installed-check' -const mockUseCheckInstalled = vi.fn() -const mockUsePluginManifestInfo = vi.fn() - -const mockManifest = { - data: { - plugin: { - name: 'test-plugin', - version: '1.0.0', - }, - }, -} - -vi.mock('@/service/use-plugins', () => ({ - useCheckInstalled: (...args: unknown[]) => mockUseCheckInstalled(...args), - usePluginManifestInfo: (...args: unknown[]) => mockUsePluginManifestInfo(...args), +const requests = vi.hoisted(() => ({ installed: vi.fn(), marketplace: vi.fn() })) +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + getMarketplace: requests.marketplace, })) -describe('usePluginInstalledCheck', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseCheckInstalled.mockImplementation( - ({ pluginIds, enabled }: { pluginIds: string[]; enabled: boolean }) => ({ - data: enabled && pluginIds.length > 0 ? { plugins: [] } : undefined, - }), - ) - mockUsePluginManifestInfo.mockImplementation((pluginID: string) => ({ - data: pluginID ? mockManifest : undefined, - })) - }) - - it('should use the explicit pluginID', () => { - const { result } = renderHook(() => - usePluginInstalledCheck({ - providerPluginId: 'org/plugin', - }), - ) - - expect(result.current.pluginID).toBe('org/plugin') - }) - - it('should detect plugin in marketplace when manifest exists', () => { - const { result } = renderHook(() => - usePluginInstalledCheck({ - providerPluginId: 'org/plugin', - }), - ) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.plugin.list.installations.ids.post': requests.installed, + }), + } +}) - expect(result.current.inMarketPlace).toBe(true) - expect(result.current.manifest).toEqual(mockManifest.data.plugin) +beforeEach(() => { + requests.installed.mockResolvedValue({ plugins: [] }) + requests.marketplace.mockResolvedValue({ + data: { plugin: { name: 'Test plugin', version: '1.0.0' } }, }) +}) - it('should handle missing plugin id', () => { - const { result } = renderHook(() => usePluginInstalledCheck()) +it('finds marketplace metadata for the requested plugin', async () => { + const { result } = renderHook(() => usePluginInstalledCheck({ providerPluginId: 'org/plugin' })) + await waitFor(() => expect(result.current.inMarketPlace).toBe(true)) + expect(result.current.pluginID).toBe('org/plugin') + expect(result.current.manifest).toMatchObject({ name: 'Test plugin' }) + expect(requests.installed).toHaveBeenCalledWith({ body: { plugin_ids: ['org/plugin'] } }) + expect(requests.marketplace).toHaveBeenCalledWith('/plugins/org/plugin') +}) +it.each([undefined, null, ''])( + 'skips requests for a non-plugin provider (%s)', + (providerPluginId) => { + const { result } = renderHook(() => usePluginInstalledCheck({ providerPluginId })) expect(result.current.pluginID).toBe('') expect(result.current.inMarketPlace).toBe(false) - }) - - it('should skip marketplace lookup when installed plugin source is local', () => { - mockUseCheckInstalled.mockReturnValue({ - data: { - plugins: [ - { - source: PluginSource.local, - }, - ], - }, - }) - - const { result } = renderHook(() => - usePluginInstalledCheck({ - providerPluginId: 'org/plugin', - enabled: true, - }), - ) - - expect(mockUsePluginManifestInfo).toHaveBeenCalledWith('') - expect(result.current.inMarketPlace).toBe(false) - }) + expect(requests.installed).not.toHaveBeenCalled() + expect(requests.marketplace).not.toHaveBeenCalled() + }, +) - it('should skip all plugin checks for non-plugin providers', () => { - const { result } = renderHook(() => - usePluginInstalledCheck({ - providerPluginId: null, - enabled: true, - }), - ) +it('skips both requests while checks are disabled', () => { + renderHook(() => usePluginInstalledCheck({ providerPluginId: 'org/plugin', enabled: false })) + expect(requests.installed).not.toHaveBeenCalled() + expect(requests.marketplace).not.toHaveBeenCalled() +}) - expect(mockUseCheckInstalled).toHaveBeenCalledWith({ - pluginIds: [], - enabled: false, - }) - expect(mockUsePluginManifestInfo).toHaveBeenCalledWith('') - expect(result.current.pluginID).toBe('') +it('does not query marketplace when the cached installation is a local package', async () => { + const queryClient = createConsoleQueryClient() + const plugins = [createPluginEntity({ source: PluginSource.local, plugin_id: 'org/plugin' })] + requests.installed.mockResolvedValue({ plugins }) + queryClient.setQueryData( + consoleQuery.workspaces.current.plugin.list.installations.ids.post.queryKey({ + input: { body: { plugin_ids: ['org/plugin'] } }, + }), + { plugins }, + ) + const { result } = renderHook(() => usePluginInstalledCheck({ providerPluginId: 'org/plugin' }), { + queryClient, }) + await waitFor(() => expect(requests.installed).toHaveBeenCalled()) + expect(result.current.inMarketPlace).toBe(false) + expect(requests.marketplace).not.toHaveBeenCalled() }) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts index ca1c24c6b297a7..7deb57e464ef4d 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/use-tool-selector.spec.ts @@ -2,20 +2,33 @@ import type { ToolValue } from '@/app/components/workflow/block-selector/types' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { CollectionType } from '@/app/components/tools/types' -import { createQueryClientWrapper } from '@/test/console/query-client' -import { createTestQueryClient } from '@/test/query-client' +import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data' +import { createToolProviderFixture } from '@/test/console/tool-provider-fixture' +import { seedToolProviders } from '@/test/console/tool-providers' import { useToolSelector } from '../use-tool-selector' -const queryClient = createTestQueryClient() -const queryClientWrapper = createQueryClientWrapper(queryClient) +let queryClient = createConsoleQueryClient() +let queryClientWrapper = createConsoleQueryWrapper({ queryClient }).wrapper const mockToolParams = [ - { name: 'param1', form: 'llm', type: 'string', required: true, label: { en_US: 'Param 1' } }, - { name: 'param2', form: 'form', type: 'number', required: false, label: { en_US: 'Param 2' } }, + { + name: 'param1', + form: 'llm' as const, + type: 'string' as const, + required: true, + label: { en_US: 'Param 1' }, + }, + { + name: 'param2', + form: 'form' as const, + type: 'number' as const, + required: false, + label: { en_US: 'Param 2' }, + }, ] const mockTools = [ - { + createToolProviderFixture({ id: 'test-provider', name: 'Test Provider', plugin_id: 'org/test-plugin', @@ -23,27 +36,14 @@ const mockTools = [ tools: [ { name: 'test-tool', + author: 'Dify', label: { en_US: 'Test Tool' }, description: { en_US: 'A test tool' }, parameters: mockToolParams, }, ], - }, + }), ] -let areToolQueriesFetched = true - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'workspaces.current.tools.builtin.get': () => ({ - data: mockTools, - isFetched: areToolQueriesFetched, - }), - 'workspaces.current.tools.api.get': () => ({ data: [], isFetched: areToolQueriesFetched }), - 'workspaces.current.tools.workflow.get': () => ({ data: [], isFetched: areToolQueriesFetched }), - 'workspaces.current.tools.mcp.get': () => ({ data: [], isFetched: areToolQueriesFetched }), - }) -}) vi.mock('@/service/use-plugins', () => ({ useInvalidateInstalledPluginList: () => vi.fn().mockResolvedValue(undefined), @@ -59,17 +59,6 @@ vi.mock('@/utils/get-icon', () => ({ getIconFromMarketPlace: () => '', })) -vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ - toolParametersToFormSchemas: (params: unknown[]) => - (params as Record[]).map((p) => ({ - ...p, - variable: p.name, - })), - generateFormValue: (value: Record) => value || {}, - getPlainValue: (value: Record) => value || {}, - getStructureValue: (value: Record) => value || {}, -})) - describe('useToolSelector', () => { const mockOnSelect = vi.fn() const _mockOnSelectMultiple = vi.fn() @@ -89,7 +78,14 @@ describe('useToolSelector', () => { beforeEach(() => { vi.clearAllMocks() - areToolQueriesFetched = true + queryClient = createConsoleQueryClient() + queryClientWrapper = createConsoleQueryWrapper({ queryClient }).wrapper + seedToolProviders(queryClient, { + builtInTools: mockTools, + customTools: [], + workflowTools: [], + mcpTools: [], + }) mockUsePluginInstalledCheck.mockReturnValue({ inMarketPlace: false, manifest: null, @@ -135,11 +131,13 @@ describe('useToolSelector', () => { // param2 has form='form' (not 'llm'), so it goes to settings expect(result.current.currentToolSettings).toHaveLength(1) - expect(result.current.currentToolSettings[0]!.name).toBe('param2') + expect(result.current.settingsFormSchemas[0]!.variable).toBe('param2') // param1 has form='llm', so it goes to params expect(result.current.currentToolParams).toHaveLength(1) - expect(result.current.currentToolParams[0]!.name).toBe('param1') + expect(result.current.paramsFormSchemas[0]).toEqual( + expect.objectContaining({ variable: 'param1', type: 'text-input', required: true }), + ) }) it('should show tab slider when both settings and params exist', () => { @@ -306,7 +304,7 @@ describe('useToolSelector', () => { }) it('should keep marketplace fallback enabled when legacy provider lists are still pending but plugin id is known', () => { - areToolQueriesFetched = false + queryClient.clear() renderHook( () => @@ -348,3 +346,18 @@ describe('useToolSelector', () => { }) }) }) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + const pending = () => new Promise(() => {}) + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.tools.builtin.get': pending, + 'workspaces.current.tools.api.get': pending, + 'workspaces.current.tools.workflow.get': pending, + 'workspaces.current.tools.mcp.get': pending, + }), + } +}) diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx index 6ab9c7b20422a9..55ce9eb109c777 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-detail-drawer.spec.tsx @@ -9,10 +9,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useLanguage: () => 'en_US', })) -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), -})) - vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ default: () => , })) @@ -25,16 +21,6 @@ vi.mock('@/app/components/plugins/card/base/org-info', () => ({ default: ({ orgName }: { orgName: string }) =>
{orgName}
, })) -vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ - triggerEventParametersToFormSchemas: (params: Array>) => - params.map((p) => ({ - label: (p.label as Record) || { en_US: p.name as string }, - type: (p.type as string) || 'text-input', - required: (p.required as boolean) || false, - description: p.description as Record | undefined, - })), -})) - vi.mock( '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', () => ({ diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx index 455bcc212c2d1d..60a6de3ab8b233 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx @@ -8,10 +8,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useLanguage: () => 'en_US', })) -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), -})) - const mockTriggerEvents = [ { name: 'event-1', diff --git a/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx index c35dc00943eb1f..a30ec4aa8383d2 100644 --- a/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-mutation-model/__tests__/index.spec.tsx @@ -42,15 +42,6 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('@/utils/format', () => ({ - formatNumber: (num: number) => num.toLocaleString(), -})) - -vi.mock('@/utils/mcp', () => ({ - shouldUseMcpIcon: (src: unknown) => - typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', -})) - vi.mock('../../base/badges/partner', () => ({ default: ({ className, text }: { className?: string; text?: string }) => (
diff --git a/web/app/components/plugins/plugin-page/__tests__/category-marketplace-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/category-marketplace-panel.spec.tsx index 4819938e5506c2..d1883a193352e8 100644 --- a/web/app/components/plugins/plugin-page/__tests__/category-marketplace-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/category-marketplace-panel.spec.tsx @@ -1,6 +1,8 @@ import type { PluginCategoryEnum } from '../../types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { PluginCategoryEnum as Category } from '../../types' import { getCategoryMarketplaceId } from '../category-marketplace' import CategoryMarketplacePanel from '../category-marketplace-panel' @@ -8,42 +10,14 @@ import CategoryMarketplacePanel from '../category-marketplace-panel' const mocks = vi.hoisted(() => ({ canInstallPlugin: true, fetchNextPage: vi.fn(), - installedPluginIds: ['installed/plugin'] as string[] | undefined, - installedPluginIdsError: false, - installedPluginIdsRefetch: vi.fn(), + getInstalledIds: vi.fn(), searchParams: undefined as unknown, })) -vi.mock('@tanstack/react-query', () => ({ - useQuery: () => ({ - data: mocks.installedPluginIds, - isError: mocks.installedPluginIdsError, - isFetching: false, - isPending: false, - refetch: mocks.installedPluginIdsRefetch, - }), -})) - vi.mock('next-themes', () => ({ useTheme: () => ({ theme: 'light' }), })) -vi.mock('@/service/console', () => ({ - consoleQuery: { - workspaces: { - current: { - plugin: { - installedIds: { - get: { - queryOptions: () => ({}), - }, - }, - }, - }, - }, - }, -})) - vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: (searchParams: unknown) => { mocks.searchParams = searchParams @@ -97,12 +71,32 @@ vi.mock('@/app/components/plugins/provider-card', () => ({ default: ({ payload }: { payload: { name: string } }) =>
{payload.name}
, })) +let seedIds = true +const render = (ui: React.ReactElement) => { + const queryClient = createConsoleQueryClient() + if (seedIds) + queryClient.setQueryDefaults(consoleQuery.workspaces.current.plugin.installedIds.get.key(), { + initialData: { plugin_ids: ['installed/plugin'] }, + }) + return renderWithConsoleQuery(ui, { queryClient }) +} +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.plugin.installedIds.get': mocks.getInstalledIds, + }), + } +}) + describe('CategoryMarketplacePanel', () => { beforeEach(() => { vi.clearAllMocks() mocks.canInstallPlugin = true - mocks.installedPluginIds = ['installed/plugin'] - mocks.installedPluginIdsError = false + seedIds = true + mocks.getInstalledIds.mockResolvedValue({ plugin_ids: ['installed/plugin'] }) mocks.searchParams = undefined }) @@ -157,16 +151,17 @@ describe('CategoryMarketplacePanel', () => { expect(screen.getByText('Calendar')).toBeInTheDocument() }) - it('shows a retry action when installed plugin IDs cannot be loaded', () => { - mocks.installedPluginIds = undefined - mocks.installedPluginIdsError = true - + it('retries installed plugin IDs after a request failure and resumes marketplace search', async () => { + seedIds = false + mocks.getInstalledIds.mockRejectedValueOnce(new Error('Unavailable')) render() - - expect(screen.getByRole('alert')).toHaveTextContent('common.errorBoundary.title') - fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' })) - expect(mocks.installedPluginIdsRefetch).toHaveBeenCalledTimes(1) + expect(await screen.findByRole('alert')).toHaveTextContent('common.errorBoundary.title') expect(mocks.searchParams).toBeUndefined() + fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' })) + await waitFor(() => expect(mocks.getInstalledIds).toHaveBeenCalledTimes(2)) + expect(await screen.findByText('Calendar')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(mocks.searchParams).toMatchObject({ exclude: ['installed/plugin'] }) }) it('supports collapsing and loading the next marketplace page', () => { diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx index f22eec9a5b9efc..be7f1a76022f8d 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -47,8 +47,8 @@ vi.mock('@/i18n/metadata', async (importOriginal) => ({ renderI18nObject: (value: Record, locale: string) => value[locale] || '', })) -vi.mock('@/service/use-plugins', () => ({ - normalizePluginCategoryListLanguage: (locale: string) => locale, +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), useInstalledPluginList: (...args: unknown[]) => mockUseInstalledPluginList(...args), useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, useRemoveFilteredInstalledPluginPageOnUnmount: (...args: unknown[]) => diff --git a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts index 34f7b37f95cb98..70110926cb59c0 100644 --- a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts @@ -1,17 +1,16 @@ import type { LangGeniusVersionInfo } from '@/context/app-context-types' // Import mocks for assertions import type { ConsoleStateFixture as BaseConsoleStateFixture } from '@/test/console/state-fixture' -import { waitFor } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { toast } from '@/app/notifications' import { useInvalidateReferenceSettings, - useMutationPluginPermissionSettings, - useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings, } from '@/service/use-plugins' import { renderHookWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import { PermissionType, PluginCategoryEnum } from '../../types' import useReferenceSetting, { useCanInstallPluginFromMarketplace } from '../use-reference-setting' @@ -59,14 +58,32 @@ vi.mock('@/context/permission-state', async () => { const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') return createPermissionStateModuleMock(() => mockConsoleState) }) -vi.mock('@/service/use-plugins', () => ({ +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), usePluginAutoUpgradeSettings: vi.fn(), usePluginPermissionSettings: vi.fn(), - useMutationPluginPermissionSettings: vi.fn(), - useMutationReferenceSettings: vi.fn(), useInvalidateReferenceSettings: vi.fn(), })) +const requests = vi.hoisted(() => ({ autoUpgrade: vi.fn(), permission: vi.fn() })) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.autoUpgrade.change.post': requests.autoUpgrade, + 'workspaces.current.plugin.permission.change.post': requests.permission, + }), + } +}) + +beforeEach(() => { + requests.autoUpgrade.mockResolvedValue({}) + requests.permission.mockResolvedValue({}) +}) + const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') describe('useReferenceSetting Hook', () => { @@ -102,16 +119,6 @@ describe('useReferenceSetting Hook', () => { }, } as ReturnType) - vi.mocked(useMutationReferenceSettings).mockReturnValue({ - mutate: vi.fn(), - isPending: false, - } as unknown as ReturnType) - - vi.mocked(useMutationPluginPermissionSettings).mockReturnValue({ - mutate: vi.fn(), - isPending: false, - } as unknown as ReturnType) - vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn()) }) @@ -334,20 +341,16 @@ describe('useReferenceSetting Hook', () => { const mockInvalidate = vi.fn() vi.mocked(useInvalidateReferenceSettings).mockReturnValue(mockInvalidate) - let onSuccessCallback: (() => void) | undefined - vi.mocked(useMutationReferenceSettings).mockImplementation((options) => { - onSuccessCallback = options?.onSuccess as () => void - return { - mutate: vi.fn(), - isPending: false, - } as unknown as ReturnType - }) - - renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) - - // Trigger the onSuccess callback - if (onSuccessCallback) onSuccessCallback() - + const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) + act(() => + result.current.setReferenceSettings({ + ...result.current.referenceSetting!, + auto_upgrade: { + ...result.current.referenceSetting!.auto_upgrade, + strategy_setting: 'latest', + }, + }), + ) await waitFor(() => { expect(mockInvalidate).toHaveBeenCalled() expect(toastSuccessSpy).toHaveBeenCalledWith('common.api.actionSuccess') @@ -382,15 +385,33 @@ describe('useReferenceSetting Hook', () => { expect(result.current.referenceSetting).toEqual(mockData) }) - it('should return isUpdatePending from mutation', () => { - vi.mocked(useMutationReferenceSettings).mockReturnValue({ - mutate: vi.fn(), - isPending: true, - } as unknown as ReturnType) - + it('tracks a pending settings request until the server responds', async () => { + const request = createDeferred() + requests.autoUpgrade.mockReturnValue(request.promise) const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) - - expect(result.current.isUpdatePending).toBe(true) + act(() => + result.current.setReferenceSettings({ + ...result.current.referenceSetting!, + auto_upgrade: { + ...result.current.referenceSetting!.auto_upgrade, + strategy_setting: 'latest', + }, + }), + ) + await waitFor(() => expect(result.current.isUpdatePending).toBe(true)) + expect(requests.autoUpgrade).toHaveBeenCalledWith({ + body: { + category: 'tool', + auto_upgrade: { + ...result.current.referenceSetting!.auto_upgrade, + strategy_setting: 'latest', + }, + }, + }) + expect(toastSuccessSpy).not.toHaveBeenCalled() + request.resolve({}) + await waitFor(() => expect(result.current.isUpdatePending).toBe(false)) + expect(toastSuccessSpy).toHaveBeenCalledWith('common.api.actionSuccess') }) it('should keep permission key access available when reference setting data is still loading', () => { @@ -517,11 +538,6 @@ describe('useCanInstallPluginFromMarketplace Hook', () => { }, } as ReturnType) - vi.mocked(useMutationReferenceSettings).mockReturnValue({ - mutate: vi.fn(), - isPending: false, - } as unknown as ReturnType) - vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn()) }) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx index c3ab27bca3f417..748cae74ea15cb 100644 --- a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx @@ -3,10 +3,6 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: unknown[]) => args.filter(Boolean).join(' '), -})) - const mockCategories = [ { name: 'tool', label: 'Tool' }, { name: 'model', label: 'Model' }, diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx index 46c28d2324972f..b763adc992daf4 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx @@ -53,7 +53,7 @@ it('clears a completed plugin installation task using the keyboard and returns f await userEvent.tab() const clear = screen.getByRole('button', { name: 'Clear Installed Plugin' }) await expect.element(clear).toHaveFocus() - expect(clear.element().checkVisibility({ checkOpacity: true })).toBe(true) + await expect.poll(() => clear.element().checkVisibility({ checkOpacity: true })).toBe(true) await userEvent.keyboard('{Enter}') expect(service.clear).toHaveBeenCalledExactlyOnceWith({ taskId: 'task-1', pluginId: 'plugin-1' }) await expect.element(taskDetails).not.toBeInTheDocument() diff --git a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx index 542f7b335731f5..5344c05df18635 100644 --- a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx @@ -2,7 +2,8 @@ import type { UpdateFromMarketPlacePayload } from '@/app/components/plugins/type import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { createPluginDeclarationFixture } from '@/test/fixtures/plugin' import UpdateFromMarketplace from '../from-market-place' const { @@ -61,27 +62,15 @@ vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => }), })) -vi.mock('@/app/components/plugins/install-plugin/utils', () => ({ - pluginManifestToCardPluginProps: (payload: unknown) => payload, -})) - vi.mock('@/service/plugins', () => ({ updateFromMarketPlace: mockUpdateFromMarketPlace, })) -vi.mock('@/service/use-plugins', () => ({ +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), usePluginTaskList: () => ({ handleInstallTaskStart: mockHandleInstallTaskStart, }), - useRemoveAutoUpgrade: () => ({ - mutateAsync: mockRemoveAutoUpgrade, - }), -})) - -vi.mock('../install-plugin/base/use-get-icon', () => ({ - default: () => ({ - getIconUrl: async (icon: string) => `https://cdn.example.com/${icon}`, - }), })) vi.mock('../downgrade-warning', () => ({ @@ -108,11 +97,10 @@ const createPayload = ( category: PluginCategoryEnum.tool, originalPackageInfo: { id: 'plugin@1.0.0', - payload: { + payload: createPluginDeclarationFixture({ version: '1.0.0', icon: 'plugin.png', - label: { en_US: 'Plugin Label' }, - } as UpdateFromMarketPlacePayload['originalPackageInfo']['payload'], + }), }, targetPackageInfo: { id: 'plugin@2.0.0', @@ -121,6 +109,18 @@ const createPayload = ( ...overrides, }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.autoUpgrade.exclude.post': (...args: unknown[]) => + mockRemoveAutoUpgrade(...args), + }), + } +}) + describe('UpdateFromMarketplace', () => { beforeEach(() => { vi.clearAllMocks() @@ -138,7 +138,7 @@ describe('UpdateFromMarketplace', () => { expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument() expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument() await waitFor(() => { - expect(screen.getByTestId('plugin-card')).toHaveTextContent('Plugin Label') + expect(screen.getByTestId('plugin-card')).toHaveTextContent('Test Plugin') }) }) @@ -194,8 +194,10 @@ describe('UpdateFromMarketplace', () => { await waitFor(() => { expect(mockRemoveAutoUpgrade).toHaveBeenCalledWith({ - plugin_id: 'plugin-1', - category: PluginCategoryEnum.tool, + body: { + plugin_id: 'plugin-1', + category: PluginCategoryEnum.tool, + }, }) expect(mockUpdateFromMarketPlace).toHaveBeenCalled() }) diff --git a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index 1466af5809ca37..9cca48ff745c61 100644 --- a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { toast } from '@/app/notifications' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { PluginCategoryEnum, PluginSource, TaskStatus } from '../../types' import DowngradeWarningModal from '../downgrade-warning' import FromGitHub from '../from-github' @@ -54,13 +54,11 @@ vi.mock('@/service/plugins', () => ({ const mockHandleInstallTaskStart = vi.fn() const mockMutateAsync = vi.fn() -vi.mock('@/service/use-plugins', () => ({ +vi.mock('@/service/use-plugins', async (importOriginal) => ({ + ...(await importOriginal()), usePluginTaskList: () => ({ handleInstallTaskStart: mockHandleInstallTaskStart, }), - useRemoveAutoUpgrade: () => ({ - mutateAsync: mockMutateAsync, - }), useVersionListOfPlugin: () => ({ data: { data: { @@ -218,6 +216,18 @@ const renderWithQueryClient = (ui: React.ReactElement) => { // Test Suites // ================================ +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleClientResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'workspaces.current.plugin.autoUpgrade.exclude.post': (...args: unknown[]) => + mockMutateAsync(...args), + }), + } +}) + describe('update-plugin', () => { beforeEach(() => { vi.clearAllMocks() @@ -746,8 +756,10 @@ describe('update-plugin', () => { // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith({ - plugin_id: 'test-plugin-id', - category: PluginCategoryEnum.tool, + body: { + plugin_id: 'test-plugin-id', + category: PluginCategoryEnum.tool, + }, }) }) await waitFor(() => { diff --git a/web/app/components/rag-pipeline/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/__tests__/index.spec.tsx index 0f5e20c15664c5..defa9098220f00 100644 --- a/web/app/components/rag-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/__tests__/index.spec.tsx @@ -17,18 +17,6 @@ vi.mock('../store', () => ({ createRagPipelineSliceSlice: vi.fn(), })) -vi.mock('@/app/components/workflow/utils', () => ({ - initialNodes: () => [{ id: 'node-1' }], - initialEdges: () => [{ id: 'edge-1' }], -})) - -vi.mock('../utils', () => ({ - processNodesWithoutDataSource: (nodes: unknown[]) => ({ - nodes, - viewport: { x: 10, y: 20, zoom: 2 }, - }), -})) - vi.mock('../components/conversion', () => ({ default: () =>
Conversion
, })) @@ -89,6 +77,6 @@ describe('RagPipeline', () => { render() - expect(screen.getByText('Pipeline 1/1/2')).toBeInTheDocument() + expect(screen.getByText('Pipeline 0/0/1')).toBeInTheDocument() }) }) diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx index 84e50a959e8964..1279e31f17ac82 100644 --- a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -32,14 +32,15 @@ vi.mock('@/context/dataset-detail', () => ({ ) => selector(mockDatasetDetailState), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.transform.datasets.byDatasetId.post': () => ({ - mutateAsync: mockConvert, - isPending: false, +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.transform.datasets.byDatasetId.post': (input) => mockConvert(input), }), - }) + } }) const { mockToast } = vi.hoisted(() => { @@ -74,6 +75,7 @@ vi.mock('@/context/permission-state', async () => { describe('Conversion', () => { beforeEach(() => { vi.clearAllMocks() + mockConvert.mockReset().mockResolvedValue({ status: 'success' }) vi.spyOn(QueryClient.prototype, 'invalidateQueries') mockDatasetDetailState = { dataset: { @@ -154,64 +156,56 @@ describe('Conversion', () => { }) }) - it('should call convert when confirm is clicked', () => { + it('should call convert when confirm is clicked', async () => { render() fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - expect(mockConvert).toHaveBeenCalledWith( - { params: { dataset_id: 'ds-123' } }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), + await waitFor(() => + expect(mockConvert).toHaveBeenCalledWith({ params: { dataset_id: 'ds-123' } }), ) }) it('should handle successful conversion', async () => { - mockConvert.mockImplementation( - (_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { - opts.onSuccess({ status: 'success' }) - }, - ) + mockConvert.mockResolvedValue({ status: 'success' }) render() fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - expect(mockToast.success).toHaveBeenCalledWith('datasetPipeline.conversion.successMessage') + await waitFor(() => + expect(mockToast.success).toHaveBeenCalledWith('datasetPipeline.conversion.successMessage'), + ) expect(QueryClient.prototype.invalidateQueries).toHaveBeenCalledWith({ queryKey: datasetDetailKey, }) }) it('should handle failed conversion', async () => { - mockConvert.mockImplementation( - (_id: string, opts: { onSuccess: (res: { status: string }) => void }) => { - opts.onSuccess({ status: 'failed' }) - }, - ) + mockConvert.mockResolvedValue({ status: 'failed' }) render() fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - expect(mockToast.error).toHaveBeenCalledWith('datasetPipeline.conversion.errorMessage') + await waitFor(() => + expect(mockToast.error).toHaveBeenCalledWith('datasetPipeline.conversion.errorMessage'), + ) }) it('should handle conversion error', async () => { - mockConvert.mockImplementation((_id: string, opts: { onError: () => void }) => { - opts.onError() - }) + mockConvert.mockRejectedValue(new Error('Request failed')) render() fireEvent.click(screen.getByText('datasetPipeline.operations.convert')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - expect(mockToast.error).toHaveBeenCalledWith('datasetPipeline.conversion.errorMessage') + await waitFor(() => + expect(mockToast.error).toHaveBeenCalledWith('datasetPipeline.conversion.errorMessage'), + ) }) }) diff --git a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx index 8a6b07281812c6..b4480bce5db4b8 100644 --- a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx @@ -62,11 +62,6 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/app/components/workflow/utils', () => ({ - initialNodes: (nodes: unknown[]) => nodes, - initialEdges: (edges: unknown[]) => edges, -})) - const mockHandleCheckPluginDependencies = vi.fn() vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ usePluginDependencies: () => ({ @@ -76,12 +71,16 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -vi.mock('@tanstack/react-query', async (importOriginal) => { - const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks') - return mockConsoleOperationHooks(await importOriginal(), { - 'rag.pipelines.imports.post': () => ({ mutateAsync: mockImportDSL }), - 'rag.pipelines.imports.byImportId.confirm.post': () => ({ mutateAsync: mockImportDSLConfirm }), - }) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'rag.pipelines.imports.post': (input) => mockImportDSL(input), + 'rag.pipelines.imports.byImportId.confirm.post': (input) => mockImportDSLConfirm(input), + }), + } }) vi.mock('@/service/workflow', () => ({ @@ -110,10 +109,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ ), })) -vi.mock('@/app/components/workflow/constants', () => ({ - WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', -})) - describe('UpdateDSLModal', () => { const mockOnCancel = vi.fn() const mockOnBackup = vi.fn() diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx index c8b20f248f390c..405ae8f6640313 100644 --- a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx +++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/chunk-card.spec.tsx @@ -28,31 +28,12 @@ vi.mock('@/app/components/datasets/formatted-text/flavours/preview-slice', () => ), })) -vi.mock('@/models/datasets', () => ({ - ChunkingMode: { - text: 'text', - parentChild: 'parent-child', - qa: 'qa', - }, -})) - -vi.mock('@/utils/format', () => ({ - formatNumber: (n: number) => String(n), -})) - vi.mock('../q-a-item', () => ({ default: ({ type, text }: { type: string; text: string }) => ( {text} ), })) -vi.mock('../types', () => ({ - QAItemType: { - Question: 'question', - Answer: 'answer', - }, -})) - const makeParentChildContent = (overrides: Partial = {}): ParentChildChunk => ({ child_contents: ['Child'], parent_content: '', diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index dfc8f8213d3ba3..f775db6af8ef76 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -33,7 +33,7 @@ const Conversion = () => { }) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [showConfirmModal, setShowConfirmModal] = useState(false) - const { mutateAsync: convert, isPending } = useMutation( + const { mutate: convert, isPending } = useMutation( consoleQuery.rag.pipelines.transform.datasets.byDatasetId.post.mutationOptions(), ) const queryClient = useQueryClient() diff --git a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts index 7ecfb0375ca057..4b931074d44700 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/hooks.spec.ts @@ -11,10 +11,6 @@ vi.mock('reactflow', () => ({ }, })) -vi.mock('zustand/react/shallow', () => ({ - useShallow: (fn: (...args: unknown[]) => unknown) => fn, -})) - let mockNodePanelWidth = 400 let mockWorkflowCanvasWidth: number | undefined = 1200 let mockOtherPanelWidth = 0 diff --git a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx index 59918decc06afa..16d22165e4b7c3 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx @@ -1,830 +1,304 @@ +import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types' import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import type { Node } from '@/app/components/workflow/types' -import type { InputVar, RAGPipelineVariable, RAGPipelineVariables } from '@/models/pipeline' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import * as React from 'react' +import type { RAGPipelineVariable } from '@/models/pipeline' +import { QueryClient } from '@tanstack/react-query' +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useNodes } from 'reactflow' +import { createRagPipelineSliceSlice } from '@/app/components/rag-pipeline/store' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { fullWorkflowAccessControl } from '@/app/components/workflow/hooks-store/store' +import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' -import { PipelineInputVarType } from '@/models/pipeline' +import { toast } from '@/app/notifications' +import { consoleQuery } from '@/service/console' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' +import { createPipelineVariable } from '@/test/pipeline-variables' +import InputFieldEditorPanel from '../editor' import InputFieldPanel from '../index' -let mockNodesData: Node[] = [] -vi.mock('reactflow', () => ({ - useNodes: () => mockNodesData, -})) +// Catalog icon lookup is independent of field editing and graph reference updates. +vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({ useToolIcon: () => '' })) -const mockCloseAllInputFieldPanels = vi.fn() -const mockToggleInputFieldPreviewPanel = vi.fn() -let mockIsPreviewing = false -let mockIsEditing = false -let mockCanEdit = true - -vi.mock('../../../../hooks/use-input-field-panel', () => ({ - useInputFieldPanel: () => ({ - closeAllInputFieldPanels: mockCloseAllInputFieldPanels, - toggleInputFieldPreviewPanel: mockToggleInputFieldPreviewPanel, - isPreviewing: mockIsPreviewing, - isEditing: mockIsEditing, - }), -})) - -vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: (selector: (state: { accessControl: { canEdit: boolean } }) => T): T => - selector({ - accessControl: { - canEdit: mockCanEdit, - }, - }), -})) - -let mockRagPipelineVariables: RAGPipelineVariables = [] -const mockSetRagPipelineVariables = vi.fn() - -type MockStoreState = { - ragPipelineVariables: RAGPipelineVariables - setRagPipelineVariables: typeof mockSetRagPipelineVariables -} - -vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: MockStoreState) => unknown) => { - const state: MockStoreState = { - ragPipelineVariables: mockRagPipelineVariables, - setRagPipelineVariables: mockSetRagPipelineVariables, - } - return selector(state) - }, - useWorkflowStore: () => ({ - getState: () => ({ - setRagPipelineVariables: mockSetRagPipelineVariables, - }), - }), -})) - -const mockHandleSyncWorkflowDraft = vi.fn() - -vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ - useNodesSyncDraft: () => ({ - handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, - }), -})) - -vi.mock('../field-list', () => ({ - default: ({ - nodeId, - LabelRightContent, - inputFields, - handleInputFieldsChange, - readonly, - labelClassName, - allVariableNames, - }: { - nodeId: string - LabelRightContent: React.ReactNode - inputFields: InputVar[] - handleInputFieldsChange: (key: string, value: InputVar[]) => void - readonly?: boolean - labelClassName?: string - allVariableNames: string[] - }) => ( -
- {String(readonly)} - {labelClassName} - {inputFields.length} - {allVariableNames.join(',')} - {LabelRightContent} - - -
- ), -})) - -vi.mock('../footer-tip', () => ({ - default: () =>
Footer Tip
, -})) - -vi.mock('../label-right-content/datasource', () => ({ - default: ({ nodeData }: { nodeData: DataSourceNodeType }) => ( -
{nodeData.title}
- ), -})) - -vi.mock('../label-right-content/global-inputs', () => ({ - default: () =>
Global Inputs
, -})) - -const createInputVar = (overrides?: Partial): InputVar => ({ - type: PipelineInputVarType.textInput, - label: 'Test Label', - variable: 'test_variable', - max_length: 48, - default_value: '', - required: true, - tooltips: '', - options: [], - placeholder: '', - unit: '', - allowed_file_upload_methods: [], - allowed_file_types: [], - allowed_file_extensions: [], - ...overrides, -}) - -const createRAGPipelineVariable = (nodeId: string, overrides?: Partial) => ({ - belong_to_node_id: nodeId, - ...createInputVar(overrides), -}) - -const createDataSourceNode = ( - id: string, - title: string, - overrides?: Partial, -): Node => ({ - id, - type: 'custom', +const source: Node = { + id: 'source-node', position: { x: 0, y: 0 }, data: { type: BlockEnum.DataSource, - title, - desc: 'Test datasource', - selected: false, - ...overrides, - } as DataSourceNodeType, + title: 'Knowledge source', + desc: '', + plugin_id: 'plugin-1', + provider_type: 'online_document', + provider_name: 'source', + datasource_name: 'source', + datasource_label: 'Source', + datasource_parameters: {}, + datasource_configurations: {}, + }, +} +const consumer = (id: string, variable: string, nodeId = 'source-node'): Node => ({ + id, + position: { x: 200, y: 0 }, + data: { + type: BlockEnum.Code, + title: id, + desc: '', + variables: [{ variable: 'input', value_selector: ['rag', nodeId, variable] }], + code_language: 'python3', + code: '', + outputs: {}, + }, }) -const setupMocks = (options?: { - nodes?: Node[] - ragPipelineVariables?: RAGPipelineVariables - isPreviewing?: boolean - isEditing?: boolean - canEdit?: boolean -}) => { - mockNodesData = options?.nodes || [] - mockRagPipelineVariables = options?.ragPipelineVariables || [] - mockIsPreviewing = options?.isPreviewing || false - mockIsEditing = options?.isEditing || false - mockCanEdit = options?.canEdit ?? true +// Connect the actual editor to the public panel state and expose the resulting graph. +const PanelHarness = () => { + const editor = useStore((s) => s.inputFieldEditPanelProps) + const nodes = useNodes>() + return ( + <> + + {editor && } + + {JSON.stringify(nodes.map((node) => ({ id: node.id, variables: node.data.variables })))} + + + ) } -describe('InputFieldPanel', () => { - beforeEach(() => { - vi.clearAllMocks() - setupMocks() - }) - - describe('Rendering', () => { - it('should render panel title correctly', () => { - render() - - expect(screen.getByText('datasetPipeline.inputFieldPanel.title'))!.toBeInTheDocument() - }) - - it('should render panel description', () => { - render() - - expect(screen.getByText('datasetPipeline.inputFieldPanel.description'))!.toBeInTheDocument() - }) - - it('should render preview button', () => { - render() - - expect(screen.getByText('datasetPipeline.operations.preview'))!.toBeInTheDocument() - }) - - it('should render close button', () => { - render() - - const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) - expect(closeButton)!.toBeInTheDocument() - }) - - it('should render footer tip component', () => { - render() - - expect(screen.getByTestId('footer-tip'))!.toBeInTheDocument() - }) - - it('should render unique inputs section title', () => { - render() - - expect( - screen.getByText('datasetPipeline.inputFieldPanel.uniqueInputs.title'), - )!.toBeInTheDocument() - }) - - it('should render global inputs field list', () => { - render() - - expect(screen.getByTestId('field-list-shared'))!.toBeInTheDocument() - expect(screen.getByTestId('global-inputs-label'))!.toBeInTheDocument() - }) - }) - - describe('DataSource Node Rendering', () => { - it('should render field list for each datasource node', () => { - const nodes = [ - createDataSourceNode('node-1', 'DataSource 1'), - createDataSourceNode('node-2', 'DataSource 2'), - ] - setupMocks({ nodes }) - - render() - - expect(screen.getByTestId('field-list-node-1'))!.toBeInTheDocument() - expect(screen.getByTestId('field-list-node-2'))!.toBeInTheDocument() - }) - - it('should render datasource label for each node', () => { - const nodes = [createDataSourceNode('node-1', 'My DataSource')] - setupMocks({ nodes }) - - render() - - expect(screen.getByTestId('datasource-label-My DataSource'))!.toBeInTheDocument() - }) - - it('should not render any datasource field lists when no nodes exist', () => { - setupMocks({ nodes: [] }) - - render() - - expect(screen.queryByTestId('field-list-node-1')).not.toBeInTheDocument() - expect(screen.getByTestId('field-list-shared'))!.toBeInTheDocument() - }) - - it('should filter only DataSource type nodes', () => { - const dataSourceNode = createDataSourceNode('ds-node', 'DataSource Node') - const otherNode = { - id: 'other-node', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - type: BlockEnum.LLM, // Not a datasource type - title: 'LLM Node', - selected: false, - }, - } as Node - mockNodesData = [dataSourceNode, otherNode] - - render() - - expect(screen.getByTestId('field-list-ds-node'))!.toBeInTheDocument() - expect(screen.queryByTestId('field-list-other-node')).not.toBeInTheDocument() - }) - }) - - describe('Input Fields Map', () => { - it('should correctly distribute variables to their nodes', () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'var1' }), - createRAGPipelineVariable('node-1', { variable: 'var2' }), - createRAGPipelineVariable('shared', { variable: 'shared_var' }), - ] - setupMocks({ nodes, ragPipelineVariables: variables }) - - render() - - expect(screen.getByTestId('field-list-fields-count-node-1'))!.toHaveTextContent('2') - expect(screen.getByTestId('field-list-fields-count-shared'))!.toHaveTextContent('1') - }) - - it('should show zero fields for nodes without variables', () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - setupMocks({ nodes, ragPipelineVariables: [] }) - - render() - - expect(screen.getByTestId('field-list-fields-count-node-1'))!.toHaveTextContent('0') - }) - - it('should pass all variable names to field lists', () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'var1' }), - createRAGPipelineVariable('shared', { variable: 'var2' }), - ] - setupMocks({ nodes, ragPipelineVariables: variables }) - - render() - - expect(screen.getByTestId('field-list-all-vars-node-1'))!.toHaveTextContent('var1,var2') - expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent('var1,var2') - }) - }) - - describe('User Interactions', () => { - const isCloseButton = (btn: HTMLElement) => - btn.classList.contains('size-6') || - btn.className.includes('shrink-0 items-center justify-center p-0.5') - - it('should call closeAllInputFieldPanels when close button is clicked', () => { - render() - const buttons = screen.getAllByRole('button') - const closeButton = buttons.find(isCloseButton) - - fireEvent.click(closeButton!) - - expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1) - }) - - it('should call toggleInputFieldPreviewPanel when preview button is clicked', () => { - render() - const previewButton = screen.getByText('datasetPipeline.operations.preview') - - fireEvent.click(previewButton) - - expect(mockToggleInputFieldPreviewPanel).toHaveBeenCalledTimes(1) - }) - - it('should disable preview button when editing', () => { - setupMocks({ isEditing: true }) - - render() - - const previewButton = screen.getByText('datasetPipeline.operations.preview').closest('button') - expect(previewButton)!.toBeDisabled() - }) - - it('should not disable preview button when not editing', () => { - setupMocks({ isEditing: false }) - - render() - - const previewButton = screen.getByText('datasetPipeline.operations.preview').closest('button') - expect(previewButton).not.toBeDisabled() - }) - }) - - describe('Preview State', () => { - it('should apply active styling when previewing', () => { - setupMocks({ isPreviewing: true }) - - render() - - const previewButton = screen.getByText('datasetPipeline.operations.preview').closest('button') - expect(previewButton)!.toHaveClass('bg-state-accent-active') - expect(previewButton)!.toHaveClass('text-text-accent') - }) - - it('should set readonly to true when previewing', () => { - setupMocks({ isPreviewing: true }) - - render() - - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('true') - }) - - it('should set readonly to true when editing', () => { - setupMocks({ isEditing: true }) - - render() - - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('true') - }) - - it('should set readonly to false when not previewing or editing', () => { - setupMocks({ isPreviewing: false, isEditing: false }) - - render() - - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('false') - }) - - it('should set readonly to true when access control cannot edit', () => { - setupMocks({ canEdit: false }) - - render() - - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('true') - }) - }) - - describe('Input Fields Change Handler', () => { - it('should update rag pipeline variables when input fields change', async () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - setupMocks({ nodes }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) - - await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalled() - }) - }) - - it('should call handleSyncWorkflowDraft when fields change', async () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - setupMocks({ nodes }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) - - await waitFor(() => { - expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() - }) - }) - - it('should place datasource node fields before global fields', async () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - const variables = [createRAGPipelineVariable('shared', { variable: 'shared_var' })] - setupMocks({ nodes, ragPipelineVariables: variables }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) - - await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalled() - }) - - const setVarsCall = mockSetRagPipelineVariables.mock.calls[0]![0] as RAGPipelineVariables - const isNotShared = (v: RAGPipelineVariable) => v.belong_to_node_id !== 'shared' - const isShared = (v: RAGPipelineVariable) => v.belong_to_node_id === 'shared' - const dsFields = setVarsCall.filter(isNotShared) - const sharedFields = setVarsCall.filter(isShared) - - if (dsFields.length > 0 && sharedFields.length > 0) { - const firstDsIndex = setVarsCall.indexOf(dsFields[0]!) - const firstSharedIndex = setVarsCall.indexOf(sharedFields[0]!) - expect(firstDsIndex).toBeLessThan(firstSharedIndex) - } - }) - - it('should handle removing all fields from a node', async () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'var1' }), - createRAGPipelineVariable('node-1', { variable: 'var2' }), - ] - setupMocks({ nodes, ragPipelineVariables: variables }) - render() - - fireEvent.click(screen.getByTestId('trigger-remove-node-1')) - - await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalled() - }) - }) - - it('should update global input fields correctly', async () => { - setupMocks() - render() - - fireEvent.click(screen.getByTestId('trigger-change-shared')) - - await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalled() - }) - - const setVarsCall = mockSetRagPipelineVariables.mock.calls[0]![0] as RAGPipelineVariables - const isSharedField = (v: RAGPipelineVariable) => v.belong_to_node_id === 'shared' - const hasSharedField = setVarsCall.some(isSharedField) - expect(hasSharedField).toBe(true) - }) - - it('should ignore input field changes when access control cannot edit', () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - setupMocks({ nodes, canEdit: false }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) - - expect(mockSetRagPipelineVariables).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - }) - - it('should ignore input field changes while previewing', () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - setupMocks({ nodes, isPreviewing: true }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) - - expect(mockSetRagPipelineVariables).not.toHaveBeenCalled() - expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() - }) - - it('should still persist input field changes while edit panel is open if access control can edit', async () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - setupMocks({ nodes, isEditing: true, canEdit: true }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) - - await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalled() - }) - expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() - }) +const renderPanel = ( + options: { + variables?: RAGPipelineVariable[] + withConsumer?: boolean + canEdit?: boolean + } = {}, +) => { + const syncDraft = vi.fn() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }) - - describe('Memoization', () => { - it('should compute allVariableNames correctly', () => { - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'alpha' }), - createRAGPipelineVariable('node-1', { variable: 'beta' }), - createRAGPipelineVariable('shared', { variable: 'gamma' }), - ] - setupMocks({ ragPipelineVariables: variables }) - - render() - - expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent( - 'alpha,beta,gamma', - ) - }) - }) - - describe('Callback Stability', () => { - const findCloseButton = (buttons: HTMLElement[]) => { - const isCloseButton = (btn: HTMLElement) => - btn.classList.contains('size-6') || - btn.className.includes('shrink-0 items-center justify-center p-0.5') - return buttons.find(isCloseButton) - } - - it('should maintain closePanel callback reference', () => { - const { rerender } = render() - - const buttons1 = screen.getAllByRole('button') - fireEvent.click(findCloseButton(buttons1)!) - const callCount1 = mockCloseAllInputFieldPanels.mock.calls.length - - rerender() - const buttons2 = screen.getAllByRole('button') - fireEvent.click(findCloseButton(buttons2)!) - - expect(mockCloseAllInputFieldPanels.mock.calls.length).toBe(callCount1 + 1) - }) - - it('should maintain togglePreviewPanel callback reference', () => { - const { rerender } = render() - - fireEvent.click(screen.getByText('datasetPipeline.operations.preview')) - const callCount1 = mockToggleInputFieldPreviewPanel.mock.calls.length - - rerender() - fireEvent.click(screen.getByText('datasetPipeline.operations.preview')) - - expect(mockToggleInputFieldPreviewPanel.mock.calls.length).toBe(callCount1 + 1) - }) + queryClient.setQueryData( + consoleQuery.files.upload.get.queryKey(), + createFileUploadConfigFixture({ workflow_file_upload_limit: 10 }), + ) + const view = renderWorkflowFlowComponent(, { + resource: { kind: 'knowledge-pipeline', pipelineId: 'pipeline-1' }, + queryClient, + injectWorkflowStoreSliceFn: createRagPipelineSliceSlice, + nodes: options.withConsumer + ? [source, consumer('consumer', 'source'), consumer('unrelated', 'source')] + : [source], + edges: options.withConsumer ? [{ id: 'edge', source: source.id, target: 'consumer' }] : [], + initialStoreState: { ragPipelineVariables: options.variables ?? [], showInputFieldPanel: true }, + hooksStoreProps: { + doSyncWorkflowDraft: syncDraft, + accessControl: { ...fullWorkflowAccessControl, canEdit: options.canEdit ?? true }, + }, }) + return { ...view, syncDraft, user: userEvent.setup() } +} - describe('Edge Cases', () => { - it('should handle empty ragPipelineVariables', () => { - setupMocks({ ragPipelineVariables: [] }) - - render() - - expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent('') - }) - - it('should handle undefined ragPipelineVariables', () => { - // @ts-expect-error Testing edge case with undefined value - mockRagPipelineVariables = undefined - - render() - - expect(screen.getByTestId('field-list-shared'))!.toBeInTheDocument() - }) - - it('should handle null variable names in allVariableNames', () => { - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'valid_var' }), - createRAGPipelineVariable('node-1', { variable: '' }), - ] - setupMocks({ ragPipelineVariables: variables }) - - render() - - expect(screen.getByTestId('field-list-shared'))!.toBeInTheDocument() - }) - - it('should handle large number of datasource nodes', () => { - const nodes = Array.from({ length: 10 }, (_, i) => - createDataSourceNode(`node-${i}`, `DataSource ${i}`), - ) - setupMocks({ nodes }) - - render() - - nodes.forEach((_, i) => { - expect(screen.getByTestId(`field-list-node-${i}`))!.toBeInTheDocument() - }) - }) - - it('should handle large number of variables', () => { - const variables = Array.from({ length: 100 }, (_, i) => - createRAGPipelineVariable('shared', { variable: `var_${i}` }), - ) - setupMocks({ ragPipelineVariables: variables }) - - render() - - expect(screen.getByTestId('field-list-fields-count-shared'))!.toHaveTextContent('100') - }) - - it('should handle special characters in variable names', () => { - const variables = [ - createRAGPipelineVariable('shared', { variable: 'var_with_underscore' }), - createRAGPipelineVariable('shared', { variable: 'varWithCamelCase' }), - ] - setupMocks({ ragPipelineVariables: variables }) - - render() - - expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent( - 'var_with_underscore,varWithCamelCase', - ) - }) - }) - - describe('Multiple Nodes Interaction', () => { - it('should handle changes to multiple nodes sequentially', async () => { - const nodes = [ - createDataSourceNode('node-1', 'DataSource 1'), - createDataSourceNode('node-2', 'DataSource 2'), - ] - setupMocks({ nodes }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) - fireEvent.click(screen.getByTestId('trigger-change-node-2')) - - await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalledTimes(2) - }) - }) - - it('should maintain separate field lists for different nodes', () => { - const nodes = [ - createDataSourceNode('node-1', 'DataSource 1'), - createDataSourceNode('node-2', 'DataSource 2'), - ] - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'var1' }), - createRAGPipelineVariable('node-2', { variable: 'var2' }), - createRAGPipelineVariable('node-2', { variable: 'var3' }), - ] - setupMocks({ nodes, ragPipelineVariables: variables }) - - render() - - expect(screen.getByTestId('field-list-fields-count-node-1'))!.toHaveTextContent('1') - expect(screen.getByTestId('field-list-fields-count-node-2'))!.toHaveTextContent('2') - }) - }) - - describe('Component Structure', () => { - it('should have overflow scroll on content area', () => { - const { container } = render() - - const scrollContainer = container.querySelector('.overflow-y-auto') - expect(scrollContainer)!.toBeInTheDocument() - }) - - it('should render header section with proper spacing', () => { - render() - - expect(screen.getByText('datasetPipeline.inputFieldPanel.title'))!.toBeInTheDocument() - expect(screen.getByText('datasetPipeline.inputFieldPanel.description'))!.toBeInTheDocument() - }) - }) - - describe('Integration with FieldList Component', () => { - it('should pass correct props to FieldList for datasource nodes', () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - const variables = [createRAGPipelineVariable('node-1', { variable: 'test_var' })] - setupMocks({ - nodes, - ragPipelineVariables: variables, - isPreviewing: true, - }) - - render() - - expect(screen.getByTestId('field-list-node-1'))!.toBeInTheDocument() - expect(screen.getByTestId('field-list-readonly-node-1'))!.toHaveTextContent('true') - expect(screen.getByTestId('field-list-fields-count-node-1'))!.toHaveTextContent('1') - }) - - it('should pass correct props to FieldList for shared node', () => { - const variables = [createRAGPipelineVariable('shared', { variable: 'shared_var' })] - setupMocks({ ragPipelineVariables: variables, isEditing: true }) - - render() - - expect(screen.getByTestId('field-list-shared'))!.toBeInTheDocument() - expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent('true') - expect(screen.getByTestId('field-list-fields-count-shared'))!.toHaveTextContent('1') - }) - }) - - describe('Variable Ordering', () => { - it('should maintain correct variable order in allVariableNames', () => { - const variables = [ - createRAGPipelineVariable('node-1', { variable: 'first' }), - createRAGPipelineVariable('node-1', { variable: 'second' }), - createRAGPipelineVariable('shared', { variable: 'third' }), - ] - setupMocks({ ragPipelineVariables: variables }) - - render() +const submit = () => screen.getByRole('button', { name: /operation.submit/ }) +const variableInput = () => screen.getByRole('textbox', { name: /variableConfig.varName/ }) - expect(screen.getByTestId('field-list-all-vars-shared'))!.toHaveTextContent( - 'first,second,third', - ) - }) +it('adds a datasource field through the editor, retains global fields and synchronizes the draft', async () => { + const global = createPipelineVariable({ + belong_to_node_id: 'shared', + variable: 'global', + label: 'Global input', }) + const { user, store, syncDraft } = renderPanel({ variables: [global] }) + expect(screen.getByText('Knowledge source')).toBeInTheDocument() + await user.click(screen.getAllByRole('button', { name: 'common.operation.add' })[0]!) + expect(screen.getByRole('button', { name: /operations.preview/ })).toBeDisabled() + await user.type(variableInput(), 'source_url') + await user.tab() + await user.type(screen.getByRole('textbox', { name: /variableConfig.maxLength/ }), '100') + await user.click(submit()) + await waitFor(() => + expect( + screen.queryByRole('textbox', { name: /variableConfig.varName/ }), + ).not.toBeInTheDocument(), + ) + expect( + screen.getByRole('button', { name: 'common.operation.edit', description: 'source_url' }), + ).toBeEnabled() + expect(store.getState().ragPipelineVariables).toEqual([ + expect.objectContaining({ + variable: 'source_url', + belong_to_node_id: 'source-node', + max_length: 100, + }), + global, + ]) + act(() => store.getState().flushPendingSync()) + expect(syncDraft).toHaveBeenCalledOnce() }) -describe('useFloatingRight Hook Integration', () => { - // Note: The hook is tested indirectly through the InputFieldPanel component - - beforeEach(() => { - vi.clearAllMocks() - setupMocks() - }) - - it('should render panel correctly with default floating state', () => { - render() - expect(screen.getByTestId('field-list-shared'))!.toBeInTheDocument() - }) +it('rejects a name used by another entrance and allows correction in the same editor', async () => { + const toastError = vi.spyOn(toast, 'error').mockReturnValue('error') + const global = createPipelineVariable({ belong_to_node_id: 'shared', variable: 'taken' }) + const local = createPipelineVariable({ variable: 'original' }) + const { user, store } = renderPanel({ variables: [global, local] }) + await user.click( + screen.getByRole('button', { name: 'common.operation.edit', description: 'original' }), + ) + await user.clear(variableInput()) + await user.type(variableInput(), 'taken') + await user.click(submit()) + await waitFor(() => + expect(toastError).toHaveBeenCalledWith( + 'datasetPipeline.inputFieldPanel.error.variableDuplicate', + ), + ) + expect(variableInput()).toHaveValue('taken') + expect(store.getState().ragPipelineVariables).toEqual([global, local]) + await user.clear(variableInput()) + await user.type(variableInput(), 'available') + await user.click(submit()) + await waitFor(() => expect(store.getState().inputFieldEditPanelProps).toBeNull()) + expect(store.getState().ragPipelineVariables?.map((v) => v.variable)).toEqual([ + 'available', + 'taken', + ]) + act(() => store.getState().flushPendingSync()) }) -describe('FooterTip Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - setupMocks() - }) - - it('should render footer tip at the bottom of the panel', () => { - render() - - expect(screen.getByTestId('footer-tip'))!.toBeInTheDocument() - }) +it('renames real downstream references while leaving another branch untouched', async () => { + const { user, store } = renderPanel({ variables: [createPipelineVariable()], withConsumer: true }) + await user.click( + screen.getByRole('button', { name: 'common.operation.edit', description: 'source' }), + ) + await user.clear(variableInput()) + await user.type(variableInput(), 'renamed') + await user.click(submit()) + await waitFor(() => expect(store.getState().inputFieldEditPanelProps).toBeNull()) + const graph = JSON.parse(screen.getByRole('status', { name: 'Graph' }).textContent!) + expect(graph.find((n: Node) => n.id === 'consumer').variables[0].value_selector).toEqual([ + 'rag', + 'source-node', + 'renamed', + ]) + expect(graph.find((n: Node) => n.id === 'unrelated').variables[0].value_selector).toEqual([ + 'rag', + 'source-node', + 'source', + ]) + expect(store.getState().ragPipelineVariables?.[0]?.variable).toBe('renamed') + act(() => store.getState().flushPendingSync()) }) -describe('Label Components Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - setupMocks() - }) - - it('should render GlobalInputs label for shared field list', () => { - render() - - expect(screen.getByTestId('global-inputs-label'))!.toBeInTheDocument() - }) - - it('should render Datasource label for each datasource node', () => { - const nodes = [ - createDataSourceNode('node-1', 'First DataSource'), - createDataSourceNode('node-2', 'Second DataSource'), - ] - setupMocks({ nodes }) +it('requires confirmation to remove a referenced field and clears only its downstream references', async () => { + const variable = createPipelineVariable() + const { user, store } = renderPanel({ variables: [variable], withConsumer: true }) + await user.click( + screen.getByRole('button', { name: 'common.operation.remove', description: 'source' }), + ) + let dialog = await screen.findByRole('alertdialog') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(store.getState().ragPipelineVariables).toEqual([variable]) + await user.click( + screen.getByRole('button', { name: 'common.operation.remove', description: 'source' }), + ) + dialog = await screen.findByRole('alertdialog') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.confirm' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(store.getState().ragPipelineVariables).toEqual([]) + const graph = JSON.parse(screen.getByRole('status', { name: 'Graph' }).textContent!) + expect(graph.find((n: Node) => n.id === 'consumer').variables[0].value_selector).toEqual([]) + expect(graph.find((n: Node) => n.id === 'unrelated').variables[0].value_selector).toEqual([ + 'rag', + 'source-node', + 'source', + ]) + act(() => store.getState().flushPendingSync()) +}) - render() +it('reorders fields without persisting sortable metadata and removes an unused field directly', async () => { + const first = createPipelineVariable({ variable: 'first' }) + const second = createPipelineVariable({ variable: 'second' }) + const { user, store } = renderPanel({ variables: [first, second] }) + await user.click( + screen.getByRole('button', { name: 'common.operation.moveDown', description: 'first' }), + ) + expect(store.getState().ragPipelineVariables).toEqual([second, first]) + await user.click( + screen.getByRole('button', { name: 'common.operation.remove', description: 'second' }), + ) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(store.getState().ragPipelineVariables).toEqual([first]) + act(() => store.getState().flushPendingSync()) +}) - expect(screen.getByTestId('datasource-label-First DataSource'))!.toBeInTheDocument() - expect(screen.getByTestId('datasource-label-Second DataSource'))!.toBeInTheDocument() +it('makes fields read-only while previewing and restores editing when preview closes', async () => { + const { user, store } = renderPanel({ variables: [createPipelineVariable()] }) + const preview = screen.getByRole('button', { name: /operations.preview/ }) + await user.click(preview) + expect(store.getState().showInputFieldPreviewPanel).toBe(true) + expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument() + for (const add of screen.getAllByRole('button', { name: 'common.operation.add' })) + expect(add).toBeDisabled() + await user.click(preview) + expect(store.getState().showInputFieldPreviewPanel).toBe(false) + expect(screen.getByRole('button', { name: 'common.operation.edit' })).toBeEnabled() + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + expect(store.getState()).toMatchObject({ + showInputFieldPanel: false, + showInputFieldPreviewPanel: false, + inputFieldEditPanelProps: null, }) }) -describe('Component Memo Behavior', () => { - beforeEach(() => { - vi.clearAllMocks() - setupMocks() +it('keeps all field mutation controls unavailable without edit permission', () => { + const { store, syncDraft } = renderPanel({ + variables: [createPipelineVariable()], + canEdit: false, }) + for (const add of screen.getAllByRole('button', { name: 'common.operation.add' })) + expect(add).toBeDisabled() + expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.remove' })).not.toBeInTheDocument() + act(() => store.getState().flushPendingSync()) + expect(syncDraft).not.toHaveBeenCalled() +}) - it('should handle state updates correctly with memo', async () => { - const nodes = [createDataSourceNode('node-1', 'DataSource 1')] - setupMocks({ nodes }) - render() - - fireEvent.click(screen.getByTestId('trigger-change-node-1')) +it('cancels editing without changing the stored field', async () => { + const variable = createPipelineVariable() + const { user, store, syncDraft } = renderPanel({ variables: [variable] }) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + await user.clear(variableInput()) + await user.type(variableInput(), 'discarded') + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + expect(screen.queryByRole('textbox', { name: /variableConfig.varName/ })).not.toBeInTheDocument() + expect(store.getState().ragPipelineVariables).toEqual([variable]) + act(() => store.getState().flushPendingSync()) + expect(syncDraft).not.toHaveBeenCalled() +}) - await waitFor(() => { - expect(mockSetRagPipelineVariables).toHaveBeenCalled() - }) +it('rejects a name added during an earlier editing session', async () => { + const toastError = vi.spyOn(toast, 'error').mockReturnValue('error') + const { user, store } = renderPanel({ + variables: [createPipelineVariable({ variable: 'original' })], }) + await user.click( + screen.getByRole('button', { name: 'common.operation.edit', description: 'original' }), + ) + await user.clear(variableInput()) + await user.type(variableInput(), 'taken_later') + await user.click(submit()) + await waitFor(() => expect(store.getState().inputFieldEditPanelProps).toBeNull()) + await user.click(screen.getAllByRole('button', { name: 'common.operation.add' })[1]!) + await user.type(variableInput(), 'taken_later') + await user.tab() + await user.type(screen.getByRole('textbox', { name: /variableConfig.maxLength/ }), '100') + await user.click(submit()) + await waitFor(() => + expect(toastError).toHaveBeenCalledWith( + 'datasetPipeline.inputFieldPanel.error.variableDuplicate', + ), + ) + expect(variableInput()).toHaveValue('taken_later') + expect(store.getState().ragPipelineVariables).toHaveLength(1) + act(() => store.getState().flushPendingSync()) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx index 5fec15e70e0bc6..cc5924dd1af126 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/index.spec.tsx @@ -1,1085 +1,108 @@ -import type { FormData } from '../form/types' -import type { InputFieldEditorProps } from '../index' -import type { SupportUploadFileTypes } from '@/app/components/workflow/types' -import type { InputVar } from '@/models/pipeline' -import type { TransferMethod } from '@/types/app' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' +import { QueryClient } from '@tanstack/react-query' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ReactFlowProvider } from 'reactflow' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { ChangeType } from '@/app/components/workflow/types' import { PipelineInputVarType } from '@/models/pipeline' +import { consoleQuery } from '@/service/console' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' +import { createPipelineVariable } from '@/test/pipeline-variables' import InputFieldEditorPanel from '../index' -import { convertFormDataToINputField, convertToInputFieldFormData } from '../utils' -const mockUseFloatingRight = vi.fn(() => ({ - floatingRight: false, - floatingRightWidth: 400, -})) - -vi.mock('../../hooks', () => ({ - useFloatingRight: () => mockUseFloatingRight(), -})) - -vi.mock('../form', () => ({ - default: ({ - initialData, - supportFile, - onCancel, - onSubmit, - isEditMode, - }: { - initialData: FormData - supportFile: boolean - onCancel: () => void - onSubmit: (value: FormData) => void - isEditMode: boolean - }) => ( -
- {JSON.stringify(initialData)} - {String(supportFile)} - {String(isEditMode)} - - -
- ), -})) - -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: () => ({ - data: { - image_file_size_limit: 10, - file_size_limit: 15, - audio_file_size_limit: 50, - video_file_size_limit: 100, - workflow_file_upload_limit: 10, - }, - isLoading: false, - error: null, - }), -})) - -const createInputVar = (overrides?: Partial): InputVar => ({ - type: PipelineInputVarType.textInput, - label: 'Test Label', - variable: 'test_variable', - max_length: 48, - default_value: '', - required: true, - tooltips: '', - options: [], - placeholder: '', - unit: '', - allowed_file_upload_methods: [], - allowed_file_types: [], - allowed_file_extensions: [], - ...overrides, -}) - -const createFormData = (overrides?: Partial): FormData => ({ - type: PipelineInputVarType.textInput, - label: 'Test Label', - variable: 'test_variable', - maxLength: 48, - default: '', - required: true, - tooltips: '', - options: [], - placeholder: '', - unit: '', - allowedFileUploadMethods: [], - allowedTypesAndExtensions: { - allowedFileTypes: [], - allowedFileExtensions: [], - }, - ...overrides, -}) - -const createInputFieldEditorProps = ( - overrides?: Partial, -): InputFieldEditorProps => ({ - onClose: vi.fn(), - onSubmit: vi.fn(), - ...overrides, -}) - -const createConsoleQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - }, - }) - -const TestWrapper = ({ children }: { children: React.ReactNode }) => { - const queryClient = createConsoleQueryClient() - return {children} -} - -const renderWithProviders = (ui: React.ReactElement) => { - return render(ui, { wrapper: TestWrapper }) +const renderEditor = (initialData?: Parameters[0]['initialData']) => { + const onSubmit = vi.fn() + const onClose = vi.fn() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }) + queryClient.setQueryData( + consoleQuery.files.upload.get.queryKey(), + createFileUploadConfigFixture({ workflow_file_upload_limit: 10 }), + ) + renderWorkflowComponent( + + + , + { queryClient }, + ) + return { onSubmit, onClose, user: userEvent.setup() } } -describe('InputFieldEditorPanel', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseFloatingRight.mockReturnValue({ - floatingRight: false, - floatingRightWidth: 400, - }) - }) - - describe('Rendering', () => { - it('should render close button', () => { - const props = createInputFieldEditorProps() - - renderWithProviders() - - const closeButton = screen.getByRole('button', { name: /Close|operation.close/ }) - expect(closeButton).toBeInTheDocument() - }) - - it('should render "Add Input Field" title when no initialData', () => { - const props = createInputFieldEditorProps({ initialData: undefined }) - - renderWithProviders() - - expect(screen.getByText('datasetPipeline.inputFieldPanel.addInputField')).toBeInTheDocument() - }) - - it('should render "Edit Input Field" title when initialData is provided', () => { - const props = createInputFieldEditorProps({ - initialData: createInputVar(), - }) - - renderWithProviders() - - expect(screen.getByText('datasetPipeline.inputFieldPanel.editInputField')).toBeInTheDocument() - }) - - it('should pass supportFile=true to form', () => { - const props = createInputFieldEditorProps() - - renderWithProviders() - - expect(screen.getByTestId('form-support-file').textContent).toBe('true') - }) - - it('should pass isEditMode=false when no initialData', () => { - const props = createInputFieldEditorProps({ initialData: undefined }) - - renderWithProviders() - - expect(screen.getByTestId('form-is-edit-mode').textContent).toBe('false') - }) - - it('should pass isEditMode=true when initialData is provided', () => { - const props = createInputFieldEditorProps({ - initialData: createInputVar(), - }) - - renderWithProviders() - - expect(screen.getByTestId('form-is-edit-mode').textContent).toBe('true') - }) - }) - - describe('Props Variations', () => { - it('should handle different input types in initialData', () => { - const typesToTest = [ - PipelineInputVarType.textInput, - PipelineInputVarType.paragraph, - PipelineInputVarType.number, - PipelineInputVarType.select, - PipelineInputVarType.singleFile, - PipelineInputVarType.multiFiles, - PipelineInputVarType.checkbox, - ] - - typesToTest.forEach((type) => { - const initialData = createInputVar({ type }) - const props = createInputFieldEditorProps({ initialData }) - - const { unmount } = renderWithProviders() - - expect(screen.getByTestId('input-field-form')).toBeInTheDocument() - unmount() - }) - }) - - it('should handle initialData with all optional fields populated', () => { - const initialData = createInputVar({ - default_value: 'default', - tooltips: 'tooltip text', - placeholder: 'placeholder text', - unit: 'kg', - options: ['opt1', 'opt2'], - allowed_file_upload_methods: ['local_file' as TransferMethod], - allowed_file_types: ['image' as SupportUploadFileTypes], - allowed_file_extensions: ['.jpg', '.png'], - }) - const props = createInputFieldEditorProps({ initialData }) - - renderWithProviders() - - expect(screen.getByTestId('input-field-form')).toBeInTheDocument() - }) - - it('should handle initialData with minimal fields', () => { - const initialData: InputVar = { +it('creates a field from user input and converts the submitted form values', async () => { + const { user, onSubmit } = renderEditor() + await user.type(screen.getByRole('textbox', { name: /variableConfig.varName/ }), 'source_url') + await user.tab() + expect(screen.getByRole('textbox', { name: /variableConfig.displayName/ })).toHaveValue( + 'source_url', + ) + await user.type(screen.getByRole('textbox', { name: /variableConfig.maxLength/ }), '48') + await user.click(screen.getByRole('button', { name: /variableConfig.showAllSettings/ })) + await user.type( + screen.getByRole('textbox', { name: /variableConfig.defaultValue/ }), + 'https://example.com', + ) + await user.type( + screen.getByRole('textbox', { name: /variableConfig.placeholder/ }), + 'Enter a URL', + ) + await user.click(screen.getByRole('button', { name: /operation.submit/ })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + variable: 'source_url', + label: 'source_url', type: PipelineInputVarType.textInput, - label: 'Min', - variable: 'min_var', - required: false, - } - const props = createInputFieldEditorProps({ initialData }) - - renderWithProviders() - - expect(screen.getByTestId('input-field-form')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onClose when close button is clicked', () => { - const onClose = vi.fn() - const props = createInputFieldEditorProps({ onClose }) - - renderWithProviders() - fireEvent.click(screen.getByRole('button', { name: /Close|operation.close/ })) - - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call onClose when form cancel is triggered', () => { - const onClose = vi.fn() - const props = createInputFieldEditorProps({ onClose }) - - renderWithProviders() - fireEvent.click(screen.getByTestId('form-cancel-btn')) - - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call onSubmit with converted data when form submits', () => { - const onSubmit = vi.fn() - const props = createInputFieldEditorProps({ onSubmit }) - - renderWithProviders() - fireEvent.click(screen.getByTestId('form-submit-btn')) - - expect(onSubmit).toHaveBeenCalledTimes(1) - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - type: expect.any(String), - variable: expect.any(String), - }), - undefined, - ) - }) - }) - - describe('Floating Right Behavior', () => { - it('should call useFloatingRight hook', () => { - const props = createInputFieldEditorProps() - - renderWithProviders() - - expect(mockUseFloatingRight).toHaveBeenCalled() - }) - }) - - describe('Callback Stability', () => { - it('should maintain stable onClose callback reference', () => { - const onClose = vi.fn() - const props = createInputFieldEditorProps({ onClose }) - - const { rerender } = renderWithProviders() - fireEvent.click(screen.getByTestId('form-cancel-btn')) - - rerender( - - - , - ) - fireEvent.click(screen.getByTestId('form-cancel-btn')) - - expect(onClose).toHaveBeenCalledTimes(2) - }) - - it('should maintain stable onSubmit callback reference', () => { - const onSubmit = vi.fn() - const props = createInputFieldEditorProps({ onSubmit }) - - const { rerender } = renderWithProviders() - fireEvent.click(screen.getByTestId('form-submit-btn')) - - rerender( - - - , - ) - fireEvent.click(screen.getByTestId('form-submit-btn')) - - expect(onSubmit).toHaveBeenCalledTimes(2) - }) - }) - - describe('Memoization', () => { - it('should recompute formData when initialData changes', () => { - const initialData1 = createInputVar({ variable: 'var1' }) - const initialData2 = createInputVar({ variable: 'var2' }) - const props1 = createInputFieldEditorProps({ initialData: initialData1 }) - const props2 = createInputFieldEditorProps({ initialData: initialData2 }) - - const { rerender } = renderWithProviders() - const firstFormData = screen.getByTestId('form-initial-data').textContent - - rerender( - - - , - ) - const secondFormData = screen.getByTestId('form-initial-data').textContent - - expect(firstFormData).not.toBe(secondFormData) - expect(firstFormData).toContain('var1') - expect(secondFormData).toContain('var2') - }) - }) - - describe('Edge Cases', () => { - it('should handle undefined initialData gracefully', () => { - const props = createInputFieldEditorProps({ initialData: undefined }) - - expect(() => renderWithProviders()).not.toThrow() - }) - - it('should handle rapid close button clicks', () => { - const onClose = vi.fn() - const props = createInputFieldEditorProps({ onClose }) - - renderWithProviders() - const closeButtons = screen.getAllByRole('button') - const closeButton = closeButtons.find((btn) => btn.querySelector('svg')) - - if (closeButton) { - fireEvent.click(closeButton) - fireEvent.click(closeButton) - fireEvent.click(closeButton) - } - - expect(onClose).toHaveBeenCalledTimes(3) - }) - - it('should handle special characters in initialData', () => { - const initialData = createInputVar({ - label: 'Test ', - variable: 'test_var', - tooltips: 'Tooltip with "quotes" and \'apostrophes\'', - }) - const props = createInputFieldEditorProps({ initialData }) - - renderWithProviders() - - expect(screen.getByTestId('input-field-form')).toBeInTheDocument() - }) - - it('should handle empty string values in initialData', () => { - const initialData = createInputVar({ - label: '', - variable: '', - default_value: '', - tooltips: '', - placeholder: '', - }) - const props = createInputFieldEditorProps({ initialData }) - - renderWithProviders() - - expect(screen.getByTestId('input-field-form')).toBeInTheDocument() - }) - }) + default_value: 'https://example.com', + placeholder: 'Enter a URL', + max_length: 48, + }), + undefined, + ), + ) }) -describe('convertToInputFieldFormData', () => { - describe('Basic Conversion', () => { - it('should convert InputVar to FormData with all fields', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.textInput, - label: 'Test', - variable: 'test_var', +it('edits a populated field and forwards rename metadata through the real form', async () => { + const { user, onSubmit } = renderEditor( + createPipelineVariable({ variable: 'before', label: 'Original', max_length: 100 }), + ) + const variable = screen.getByRole('textbox', { name: /variableConfig.varName/ }) + expect(variable).toHaveValue('before') + await user.clear(variable) + await user.type(variable, 'after') + await user.click(screen.getByRole('button', { name: /operation.submit/ })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + variable: 'after', + label: 'Original', max_length: 100, - default_value: 'default', - required: true, - tooltips: 'tooltip', - options: ['a', 'b'], - placeholder: 'placeholder', - unit: 'kg', - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.textInput) - expect(result.label).toBe('Test') - expect(result.variable).toBe('test_var') - expect(result.maxLength).toBe(100) - expect(result.default).toBe('default') - expect(result.required).toBe(true) - expect(result.tooltips).toBe('tooltip') - expect(result.options).toEqual(['a', 'b']) - expect(result.placeholder).toBe('placeholder') - expect(result.unit).toBe('kg') - }) - - it('should convert file-related fields correctly', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.singleFile, - allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[], - allowed_file_types: ['image', 'document'] as SupportUploadFileTypes[], - allowed_file_extensions: ['.jpg', '.pdf'], - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.allowedFileUploadMethods).toEqual(['local_file', 'remote_url']) - expect(result.allowedTypesAndExtensions).toEqual({ - allowedFileTypes: ['image', 'document'], - allowedFileExtensions: ['.jpg', '.pdf'], - }) - }) - - it('should return default template when data is undefined', () => { - const result = convertToInputFieldFormData(undefined) - - expect(result.type).toBe(PipelineInputVarType.textInput) - expect(result.variable).toBe('') - expect(result.label).toBe('') - expect(result.required).toBe(true) - }) - }) - - describe('Optional Fields Handling', () => { - it('should not include default when default_value is undefined', () => { - const inputVar = createInputVar({ - default_value: undefined, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.default).toBeUndefined() - }) - - it('should not include default when default_value is null', () => { - const inputVar: InputVar = { - ...createInputVar(), - default_value: null as unknown as string, - } - - const result = convertToInputFieldFormData(inputVar) - - expect(result.default).toBeUndefined() - }) - - it('should include default when default_value is empty string', () => { - const inputVar = createInputVar({ - default_value: '', - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.default).toBe('') - }) - - it('should not include tooltips when undefined', () => { - const inputVar = createInputVar({ - tooltips: undefined, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.tooltips).toBeUndefined() - }) - - it('should not include placeholder when undefined', () => { - const inputVar = createInputVar({ - placeholder: undefined, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.placeholder).toBeUndefined() - }) - - it('should not include unit when undefined', () => { - const inputVar = createInputVar({ - unit: undefined, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.unit).toBeUndefined() - }) - - it('should not include file settings when allowed_file_upload_methods is undefined', () => { - const inputVar = createInputVar({ - allowed_file_upload_methods: undefined, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.allowedFileUploadMethods).toBeUndefined() - }) - - it('should not include allowedTypesAndExtensions details when file types/extensions are missing', () => { - const inputVar = createInputVar({ - allowed_file_types: undefined, - allowed_file_extensions: undefined, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.allowedTypesAndExtensions).toEqual({}) - }) - }) - - describe('Type-Specific Handling', () => { - it('should handle textInput type', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.textInput, - max_length: 256, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.textInput) - expect(result.maxLength).toBe(256) - }) - - it('should handle paragraph type', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.paragraph, - max_length: 1000, - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.paragraph) - expect(result.maxLength).toBe(1000) - }) - - it('should handle number type with unit', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.number, - unit: 'meters', - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.number) - expect(result.unit).toBe('meters') - }) - - it('should handle select type with options', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.select, - options: ['Option A', 'Option B', 'Option C'], - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.select) - expect(result.options).toEqual(['Option A', 'Option B', 'Option C']) - }) - - it('should handle singleFile type', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.singleFile, - allowed_file_upload_methods: ['local_file'] as TransferMethod[], - allowed_file_types: ['image'] as SupportUploadFileTypes[], - allowed_file_extensions: ['.jpg'], - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.singleFile) - expect(result.allowedFileUploadMethods).toEqual(['local_file']) - }) - - it('should handle multiFiles type', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.multiFiles, - max_length: 5, - allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[], - allowed_file_types: ['document'] as SupportUploadFileTypes[], - allowed_file_extensions: ['.pdf', '.doc'], - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.multiFiles) - expect(result.maxLength).toBe(5) - }) - - it('should handle checkbox type', () => { - const inputVar = createInputVar({ - type: PipelineInputVarType.checkbox, - default_value: 'true', - }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.type).toBe(PipelineInputVarType.checkbox) - expect(result.default).toBe('true') - }) - }) -}) - -describe('convertFormDataToINputField', () => { - describe('Basic Conversion', () => { - it('should convert FormData to InputVar with all fields', () => { - const formData = createFormData({ - type: PipelineInputVarType.textInput, - label: 'Test', - variable: 'test_var', - maxLength: 100, - default: 'default', - required: true, - tooltips: 'tooltip', - options: ['a', 'b'], - placeholder: 'placeholder', - unit: 'kg', - allowedFileUploadMethods: ['local_file'] as TransferMethod[], - allowedTypesAndExtensions: { - allowedFileTypes: ['image'] as SupportUploadFileTypes[], - allowedFileExtensions: ['.jpg'], - }, - }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.textInput) - expect(result.label).toBe('Test') - expect(result.variable).toBe('test_var') - expect(result.max_length).toBe(100) - expect(result.default_value).toBe('default') - expect(result.required).toBe(true) - expect(result.tooltips).toBe('tooltip') - expect(result.options).toEqual(['a', 'b']) - expect(result.placeholder).toBe('placeholder') - expect(result.unit).toBe('kg') - expect(result.allowed_file_upload_methods).toEqual(['local_file']) - expect(result.allowed_file_types).toEqual(['image']) - expect(result.allowed_file_extensions).toEqual(['.jpg']) - }) - - it('should handle undefined optional fields', () => { - const formData = createFormData({ - default: undefined, - tooltips: undefined, - placeholder: undefined, - unit: undefined, - allowedFileUploadMethods: undefined, - allowedTypesAndExtensions: { - allowedFileTypes: undefined, - allowedFileExtensions: undefined, - }, - }) - - const result = convertFormDataToINputField(formData) - - expect(result.default_value).toBeUndefined() - expect(result.tooltips).toBeUndefined() - expect(result.placeholder).toBeUndefined() - expect(result.unit).toBeUndefined() - expect(result.allowed_file_upload_methods).toBeUndefined() - expect(result.allowed_file_types).toBeUndefined() - expect(result.allowed_file_extensions).toBeUndefined() - }) - }) - - describe('Field Mapping', () => { - it('should map maxLength to max_length', () => { - const formData = createFormData({ maxLength: 256 }) - - const result = convertFormDataToINputField(formData) - - expect(result.max_length).toBe(256) - }) - - it('should map default to default_value', () => { - const formData = createFormData({ default: 'my default' }) - - const result = convertFormDataToINputField(formData) - - expect(result.default_value).toBe('my default') - }) - - it('should map allowedFileUploadMethods to allowed_file_upload_methods', () => { - const formData = createFormData({ - allowedFileUploadMethods: ['local_file', 'remote_url'] as TransferMethod[], - }) - - const result = convertFormDataToINputField(formData) - - expect(result.allowed_file_upload_methods).toEqual(['local_file', 'remote_url']) - }) - - it('should map allowedTypesAndExtensions to separate fields', () => { - const formData = createFormData({ - allowedTypesAndExtensions: { - allowedFileTypes: ['image', 'document'] as SupportUploadFileTypes[], - allowedFileExtensions: ['.jpg', '.pdf'], - }, - }) - - const result = convertFormDataToINputField(formData) - - expect(result.allowed_file_types).toEqual(['image', 'document']) - expect(result.allowed_file_extensions).toEqual(['.jpg', '.pdf']) - }) - }) - - describe('Type-Specific Handling', () => { - it('should preserve textInput type', () => { - const formData = createFormData({ type: PipelineInputVarType.textInput }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.textInput) - }) - - it('should preserve paragraph type', () => { - const formData = createFormData({ type: PipelineInputVarType.paragraph }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.paragraph) - }) - - it('should preserve select type with options', () => { - const formData = createFormData({ - type: PipelineInputVarType.select, - options: ['A', 'B', 'C'], - }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.select) - expect(result.options).toEqual(['A', 'B', 'C']) - }) - - it('should preserve number type with unit', () => { - const formData = createFormData({ - type: PipelineInputVarType.number, - unit: 'kg', - }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.number) - expect(result.unit).toBe('kg') - }) - - it('should preserve singleFile type', () => { - const formData = createFormData({ - type: PipelineInputVarType.singleFile, - }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.singleFile) - }) - - it('should preserve multiFiles type with maxLength', () => { - const formData = createFormData({ - type: PipelineInputVarType.multiFiles, - maxLength: 10, - }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.multiFiles) - expect(result.max_length).toBe(10) - }) - - it('should preserve checkbox type', () => { - const formData = createFormData({ type: PipelineInputVarType.checkbox }) - - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(PipelineInputVarType.checkbox) - }) - }) + }), + { type: ChangeType.changeVarName, payload: { beforeKey: 'before', afterKey: 'after' } }, + ), + ) }) -describe('Round-Trip Conversion', () => { - it('should preserve data through round-trip conversion for textInput', () => { - const original = createInputVar({ - type: PipelineInputVarType.textInput, - label: 'Test Label', - variable: 'test_var', - max_length: 100, - default_value: 'default', - required: true, - tooltips: 'tooltip', - placeholder: 'placeholder', - }) - - const formData = convertToInputFieldFormData(original) - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(original.type) - expect(result.label).toBe(original.label) - expect(result.variable).toBe(original.variable) - expect(result.max_length).toBe(original.max_length) - expect(result.default_value).toBe(original.default_value) - expect(result.required).toBe(original.required) - expect(result.tooltips).toBe(original.tooltips) - expect(result.placeholder).toBe(original.placeholder) - }) - - it('should preserve data through round-trip conversion for select', () => { - const original = createInputVar({ - type: PipelineInputVarType.select, - options: ['Option A', 'Option B', 'Option C'], - default_value: 'Option A', - }) - - const formData = convertToInputFieldFormData(original) - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(original.type) - expect(result.options).toEqual(original.options) - expect(result.default_value).toBe(original.default_value) - }) - - it('should preserve data through round-trip conversion for file types', () => { - const original = createInputVar({ - type: PipelineInputVarType.multiFiles, - max_length: 5, - allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[], - allowed_file_types: ['image', 'document'] as SupportUploadFileTypes[], - allowed_file_extensions: ['.jpg', '.pdf'], - }) - - const formData = convertToInputFieldFormData(original) - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(original.type) - expect(result.max_length).toBe(original.max_length) - expect(result.allowed_file_upload_methods).toEqual(original.allowed_file_upload_methods) - expect(result.allowed_file_types).toEqual(original.allowed_file_types) - expect(result.allowed_file_extensions).toEqual(original.allowed_file_extensions) - }) - - it('should handle all input types through round-trip', () => { - const typesToTest = [ - PipelineInputVarType.textInput, - PipelineInputVarType.paragraph, - PipelineInputVarType.number, - PipelineInputVarType.select, - PipelineInputVarType.singleFile, - PipelineInputVarType.multiFiles, - PipelineInputVarType.checkbox, - ] - - typesToTest.forEach((type) => { - const original = createInputVar({ type }) - - const formData = convertToInputFieldFormData(original) - const result = convertFormDataToINputField(formData) - - expect(result.type).toBe(original.type) - }) - }) +it.each([ + { type: PipelineInputVarType.number, default_value: 42 }, + { type: PipelineInputVarType.checkbox, default_value: true }, +])('preserves the typed default for $type when editing', async ({ type, default_value }) => { + const { user, onSubmit } = renderEditor(createPipelineVariable({ type, default_value })) + await user.click(screen.getByRole('button', { name: /operation.submit/ })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ type, default_value }), + undefined, + ), + ) }) -describe('Edge Cases', () => { - describe('convertToInputFieldFormData edge cases', () => { - it('should handle zero maxLength', () => { - const inputVar = createInputVar({ max_length: 0 }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.maxLength).toBe(0) - }) - - it('should handle empty options array', () => { - const inputVar = createInputVar({ options: [] }) - - const result = convertToInputFieldFormData(inputVar) - - expect(result.options).toEqual([]) - }) - - it('should handle options with special characters', () => { - const inputVar = createInputVar({ - options: ['